openvibers/openvibe.vip Commits
Gated-resource rules are per creator and the product names the owner: POST /policies/evaluate takes owner and applies only that creator's rule (none → owner_required, a pinned rule of another creator → owner_mismatch); a creator who claims someone else's reference first no longer blocks the owner (was 409) or gates it to their own members
Changes
@@@ -166,9 +166,9 @@ handle or `network`.| `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing || `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` || `GET\|POST /entitlements/check` `{subject, creator, mode}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }` |--| `GET /policies?service=&type=&id=` / `?creator=` | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules |++| `GET /policies?service=&type=&id=&owner=` / `?creator=` (services must name `owner`; a person defaults to themselves) | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules || `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` *(proposed addition)* | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |--| `POST /policies/evaluate` `{subject, resource, rule_id?, mode?}` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |++| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** || `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,@@@ -9,7 +9,7 @@* audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });* const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });*-- * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' } });++ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject });* if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED** const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…' }); // e.active@@@ -46,9 +46,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = nullconst note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };/** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */-- async function evaluate({ subject = null, resource, ruleId, mode } = {}) {++ async function evaluate({ subject = null, resource, owner = null, ruleId, mode } = {}) {try {-- const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, rule_id: ruleId, mode });++ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode });return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };} catch (err) {note('evaluate', err);@@@ -264,7 +264,11 @@ function v1Router({ domain, apiAuth }) {needAuth(p);if (req.query.service || req.query.type || req.query.id) {const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });-- const rule = policies.forResource(resource);++ // Rules are per creator: name the owner (services must; a person defaults to themselves).++ const owner = req.query.owner ? policies.ownerCreator(req.query.owner === 'network' ? 'network' : String(req.query.owner))++ : (p.kind === 'user' ? creators.bySubject(p.subject) : null);++ if (!req.query.owner && p.kind !== 'user') fail(422, 'vip.invalid_input', 'owner (the creator\'s usr_ id or network) is required');++ const rule = owner ? policies.forResource(resource, owner.id) : null;if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');return res.json({ rule: policies.present(rule) });@@@ -308,7 +312,7 @@ function v1Router({ domain, apiAuth }) {const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;subject = s ? String(s) : null;}-- const out = await policies.evaluate({ subject, resource: b.resource, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });++ const out = await policies.evaluate({ subject, resource: b.resource, owner: b.owner || null, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });res.json(out);}));@@@ -157,8 +157,10 @@ CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),CHECK (requirement <> 'perk' OR perk_key IS NOT NULL));--CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource-- ON vip_gated_resource_rules (resource_service, resource_type, resource_id) WHERE status = 'active';++-- One active rule per (resource, creator): the product names the owner when it asks (policies.js).++DROP INDEX IF EXISTS idx_vip_rules_resource;++CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource_creator++ ON vip_gated_resource_rules (resource_service, resource_type, resource_id, creator_id) WHERE status = 'active';CREATE TABLE IF NOT EXISTS vip_migration_maps (source TEXT NOT NULL,@@@ -5,7 +5,12 @@* members of one plan, or for members whose plan VERSION includes perk K. A product stores the* resource; VIP stores only the typed reference (EntityRef) and the rule.*-- * evaluate() FAILS CLOSED: no rule, a disabled rule, a suspended creator, an entitlement Billing++ * A rule belongs to one creator, and several creators may each hold a rule for the same reference:++ * VIP cannot tell who owns a product's resource, so the PRODUCT names the owner when it asks++ * (evaluate's `owner`) and only that creator's rule applies. Otherwise anyone could claim a++ * reference first — blocking the real owner (409) and gating it to their own members.++ *++ * evaluate() FAILS CLOSED: no owner, no rule, a disabled rule, a suspended creator, an entitlement Billing* cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules* marked sensitive always ask Billing directly (never the projection).*/@@@ -13,8 +18,14 @@ const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../utfunction createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;-- const forResource = (r) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND status = 'active'`)-- .get(r.service, r.type, r.id) || null;++ const forResource = (r, creatorId) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND creator_id = ? AND status = 'active'`)++ .get(r.service, r.type, r.id, creatorId) || null;++ /** The creator a product names as a resource's owner: SubjectRef, usr_ id, or 'network'. */++ function ownerCreator(owner) {++ if (owner === 'network') return creators.network();++ const s = owner && typeof owner === 'object' ? owner.id : owner;++ return isUserSubject(String(s || '')) ? creators.bySubject(String(s)) : null;++ }/** Create or replace the active rule for a resource. */function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {@@@ -30,8 +41,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlemperkKey = text(perkKey, 'perk_key', 48, { required: true });if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);} else perkKey = null;-- const existing = forResource(r);-- if (existing && existing.creator_id !== creatorId) fail(409, 'vip.rule_conflict', 'another creator already gates this resource');++ const existing = forResource(r, creatorId);const at = iso(now());const id = prefixedId('vgr', now());db.transaction(() => {@@@ -65,15 +75,20 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlemconst deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });/**-- * May `subject` see `resource`? { allow, reason, rule, entitlement }. `ruleId` pins the rule the-- * product believes applies (a mismatch denies). mode: 'auto' (default) or 'authoritative'.++ * May `subject` see `resource`, owned (says the product) by creator `owner`? { allow, reason, rule,++ * entitlement }. `ruleId` pins the rule the product believes applies (a mismatch denies).++ * mode: 'auto' (default) or 'authoritative'.*/-- async function evaluate({ subject, resource, ruleId = null, mode = 'auto', traceparent }) {++ async function evaluate({ subject, resource, owner = null, ruleId = null, mode = 'auto', traceparent }) {let r;try { r = entityRef(resource); } catch { return deny('invalid_resource'); }-- const rule = ruleId ? byId(String(ruleId)) : forResource(r);++ if (!owner) return deny('owner_required');++ const oc = ownerCreator(owner);++ if (!oc) return deny('no_rule');++ const rule = ruleId ? byId(String(ruleId)) : forResource(r, oc.id);if (!rule) return deny('no_rule');if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });++ if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: present(rule) });if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });const creator = creators.byId(rule.creator_id);if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });@@@ -93,7 +108,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlemreturn { allow: true, reason: 'member', rule: present(rule), entitlement: ent };}-- return { byId, forResource, set, disable, list, present, evaluate };++ return { byId, forResource, ownerCreator, set, disable, list, present, evaluate };}module.exports = { createPolicies };@@@ -17,7 +17,7 @@ const { test, run } = harness('policies');const stranger = t.network.newUser('hal');const blog = { cap: ['vip.resource.policy.evaluate'], sub: 'svc:blog' };const post = { service: 'blog', type: 'post', id: '42' };-- const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, ...extra } })).json;++ const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, owner: creator.subject, ...extra } })).json;let plan;await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Backstage posts', key: 'backstage', kind: 'gated_content', bindings: [{ product: 'blog', binding: 'gated_post' }] } });@@@ -26,6 +26,23 @@ const { test, run } = harness('policies');await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { perks: [] } }); // v2 drops itawait t.deliverAll(t.billing.pay(member.subject, creator.subject).events); // joins under v2++ test('a reference claimed first by another creator neither blocks the owner nor applies to them', async () => {++ const squatter = t.network.newUser('sly', { role: 'streamer' });++ const res = { service: 'blog', type: 'post', id: 'squat-1' };++ const s1 = await t.call('POST', '/api/v1/policies', { user: squatter, body: { resource: res, requirement: 'member' } });++ assert.strictEqual(s1.status, 201, s1.text);++ const d0 = await evaluate(member.subject, res);++ assert.strictEqual(d0.allow, false);++ assert.strictEqual(d0.reason, 'no_rule', 'the squatter\'s rule is not the owner\'s');++ const own = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'member' } });++ assert.strictEqual(own.status, 201, 'the real owner is not blocked: ' + own.text);++ assert.strictEqual((await evaluate(member.subject, res)).allow, true);++ const pinned = await evaluate(member.subject, res, { rule_id: s1.json.rule.id });++ assert.strictEqual(pinned.reason, 'owner_mismatch');++ const noOwner = (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject: member.subject, resource: res } })).json;++ assert.strictEqual(noOwner.reason, 'owner_required');++ });++test('no rule for a resource → denied', async () => {const d = await evaluate(member.subject);assert.deepStrictEqual([d.allow, d.reason], [false, 'no_rule']);@@@ -103,7 +120,7 @@ const { test, run } = harness('policies');});test('rule pinning, disabled rules, malformed resources and unknown rules all deny', async () => {-- const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42`, { cap: ['vip.resource.policy.get'] })).json.rule;++ const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42&owner=${creator.subject}`, { cap: ['vip.resource.policy.get'] })).json.rule;assert.ok(rule && rule.id);assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).allow, true);assert.strictEqual((await evaluate(member.subject, { service: 'blog', type: 'post', id: 'other' }, { rule_id: rule.id })).reason, 'rule_mismatch');@@@ -115,19 +132,22 @@ const { test, run } = harness('policies');assert.strictEqual((await evaluate(member.subject, post)).reason, 'no_rule');});-- test('only the creator sets rules; another creator cannot take over a gated resource', async () => {++ test('only the creator sets rules; another creator\'s rule cannot take over a gated resource', async () => {const res = { service: 'blog', type: 'post', id: 'mine' };await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });const other = t.network.newUser('kim', { role: 'streamer' });++ // Another creator's rule for the same reference is theirs alone: it never applies when the++ // product names the real owner, so it cannot take the resource over.const r = await t.call('POST', '/api/v1/policies', { user: other, body: { resource: res } });-- assert.strictEqual(r.status, 409);++ assert.strictEqual(r.status, 201);++ assert.strictEqual((await evaluate(member.subject, res)).allow, true, 'the owner\'s rule still decides');const svcNo = await t.call('POST', '/api/v1/policies', { cap: ['vip.resource.policy.evaluate'], body: { creator: { type: 'user', id: creator.subject }, resource: res } });assert.strictEqual(svcNo.status, 403);const evalNo = await t.call('POST', '/api/v1/policies/evaluate', { cap: ['vip.entitlement.check'], body: { subject: member.subject, resource: res } });assert.strictEqual(evalNo.status, 403);-- const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res } });++ const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res, owner: creator.subject } });assert.strictEqual(self.json.allow, true);-- const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res } });++ const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res, owner: creator.subject } });assert.strictEqual(spy.status, 403);});@@@ -135,20 +155,20 @@ const { test, run } = harness('policies');const res = { service: 'blog', type: 'post', id: 'mine' };const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };const vip = createVipClient({ baseUrl: t.base, tokenClient });-- assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res })).allow, true);-- assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res })).allow, false);++ assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, true);++ assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res, owner: creator.subject })).allow, false);assert.strictEqual(await vip.isMember(member.subject, creator.subject), true);assert.strictEqual(await vip.isMember(stranger.subject, creator.subject), false);const down = createVipClient({ baseUrl: 'http://127.0.0.1:9', tokenClient, timeoutMs: 500 });-- const d = await down.evaluate({ subject: member.subject, resource: res });++ const d = await down.evaluate({ subject: member.subject, resource: res, owner: creator.subject });assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);assert.strictEqual(await down.isMember(member.subject, creator.subject), false);const noCap = createVipClient({ baseUrl: t.base, tokenClient: { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: [] })}` }) } });-- assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res })).allow, false);++ assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, false);const badToken = createVipClient({ baseUrl: t.base, getToken: async () => 'not-a-token' });-- assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res })).allow, false);++ assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, false);const tokenFails = createVipClient({ baseUrl: t.base, getToken: async () => { throw new Error('no grant'); } });assert.strictEqual((await tokenFails.checkEntitlement({ subject: member.subject, creator: creator.subject })).status, 'unknown');const garbage = createVipClient({ baseUrl: t.base, tokenClient, fetch: async () => new Response('{"allow":"yes"}', { status: 200 }) });
@@ -166,9 +166,9 @@ handle or `network`.
@@ -166,9 +166,9 @@ handle or `network`.
| `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing |
| `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing |
| `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` |
| `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` |
| `GET\|POST /entitlements/check` `{subject, creator, mode}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }` |
| `GET\|POST /entitlements/check` `{subject, creator, mode}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }` |
-| `GET /policies?service=&type=&id=` / `?creator=` | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules |
+| `GET /policies?service=&type=&id=&owner=` / `?creator=` (services must name `owner`; a person defaults to themselves) | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules |
| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` *(proposed addition)* | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` *(proposed addition)* | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
-| `POST /policies/evaluate` `{subject, resource, rule_id?, mode?}` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |
+| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |
| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |
| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |
Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,
Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
* audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
* const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });
* const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });
*
*
- * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' } });
+ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject });
* if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
* if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
*
*
* const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…' }); // e.active
* const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…' }); // e.active
@@ -46,9 +46,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
@@ -46,9 +46,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
/** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */
/** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */
- async function evaluate({ subject = null, resource, ruleId, mode } = {}) {
+ async function evaluate({ subject = null, resource, owner = null, ruleId, mode } = {}) {
try {
try {
- const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, rule_id: ruleId, mode });
+ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode });
return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
} catch (err) {
} catch (err) {
note('evaluate', err);
note('evaluate', err);
@@ -264,7 +264,11 @@ function v1Router({ domain, apiAuth }) {
@@ -264,7 +264,11 @@ function v1Router({ domain, apiAuth }) {
needAuth(p);
needAuth(p);
if (req.query.service || req.query.type || req.query.id) {
if (req.query.service || req.query.type || req.query.id) {
const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });
const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });
- const rule = policies.forResource(resource);
+ // Rules are per creator: name the owner (services must; a person defaults to themselves).
+ const owner = req.query.owner ? policies.ownerCreator(req.query.owner === 'network' ? 'network' : String(req.query.owner))
+ : (p.kind === 'user' ? creators.bySubject(p.subject) : null);
+ if (!req.query.owner && p.kind !== 'user') fail(422, 'vip.invalid_input', 'owner (the creator\'s usr_ id or network) is required');
+ const rule = owner ? policies.forResource(resource, owner.id) : null;
if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);
if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);
if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');
if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');
return res.json({ rule: policies.present(rule) });
return res.json({ rule: policies.present(rule) });
@@ -308,7 +312,7 @@ function v1Router({ domain, apiAuth }) {
@@ -308,7 +312,7 @@ function v1Router({ domain, apiAuth }) {
const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;
const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;
subject = s ? String(s) : null;
subject = s ? String(s) : null;
}
}
- const out = await policies.evaluate({ subject, resource: b.resource, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });
+ const out = await policies.evaluate({ subject, resource: b.resource, owner: b.owner || null, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });
res.json(out);
res.json(out);
}));
}));
@@ -157,8 +157,10 @@ CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (
@@ -157,8 +157,10 @@ CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (
CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),
CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),
CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)
CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)
);
);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource
- ON vip_gated_resource_rules (resource_service, resource_type, resource_id) WHERE status = 'active';
+-- One active rule per (resource, creator): the product names the owner when it asks (policies.js).
+DROP INDEX IF EXISTS idx_vip_rules_resource;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource_creator
+ ON vip_gated_resource_rules (resource_service, resource_type, resource_id, creator_id) WHERE status = 'active';
CREATE TABLE IF NOT EXISTS vip_migration_maps (
CREATE TABLE IF NOT EXISTS vip_migration_maps (
source TEXT NOT NULL,
source TEXT NOT NULL,
@@ -5,7 +5,12 @@
@@ -5,7 +5,12 @@
* members of one plan, or for members whose plan VERSION includes perk K. A product stores the
* members of one plan, or for members whose plan VERSION includes perk K. A product stores the
* resource; VIP stores only the typed reference (EntityRef) and the rule.
* resource; VIP stores only the typed reference (EntityRef) and the rule.
*
*
- * evaluate() FAILS CLOSED: no rule, a disabled rule, a suspended creator, an entitlement Billing
+ * A rule belongs to one creator, and several creators may each hold a rule for the same reference:
+ * VIP cannot tell who owns a product's resource, so the PRODUCT names the owner when it asks
+ * (evaluate's `owner`) and only that creator's rule applies. Otherwise anyone could claim a
+ * reference first — blocking the real owner (409) and gating it to their own members.
+ *
+ * evaluate() FAILS CLOSED: no owner, no rule, a disabled rule, a suspended creator, an entitlement Billing
* cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules
* cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules
* marked sensitive always ask Billing directly (never the projection).
* marked sensitive always ask Billing directly (never the projection).
*/
*/
@@ -13,8 +18,14 @@ const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../ut
@@ -13,8 +18,14 @@ const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../ut
function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {
function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {
const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;
const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;
- const forResource = (r) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND status = 'active'`)
- .get(r.service, r.type, r.id) || null;
+ const forResource = (r, creatorId) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND creator_id = ? AND status = 'active'`)
+ .get(r.service, r.type, r.id, creatorId) || null;
+ /** The creator a product names as a resource's owner: SubjectRef, usr_ id, or 'network'. */
+ function ownerCreator(owner) {
+ if (owner === 'network') return creators.network();
+ const s = owner && typeof owner === 'object' ? owner.id : owner;
+ return isUserSubject(String(s || '')) ? creators.bySubject(String(s)) : null;
+ }
/** Create or replace the active rule for a resource. */
/** Create or replace the active rule for a resource. */
function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {
function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {
@@ -30,8 +41,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
@@ -30,8 +41,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
perkKey = text(perkKey, 'perk_key', 48, { required: true });
perkKey = text(perkKey, 'perk_key', 48, { required: true });
if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);
if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);
} else perkKey = null;
} else perkKey = null;
- const existing = forResource(r);
- if (existing && existing.creator_id !== creatorId) fail(409, 'vip.rule_conflict', 'another creator already gates this resource');
+ const existing = forResource(r, creatorId);
const at = iso(now());
const at = iso(now());
const id = prefixedId('vgr', now());
const id = prefixedId('vgr', now());
db.transaction(() => {
db.transaction(() => {
@@ -65,15 +75,20 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
@@ -65,15 +75,20 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
const deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });
const deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });
/**
/**
- * May `subject` see `resource`? { allow, reason, rule, entitlement }. `ruleId` pins the rule the
- * product believes applies (a mismatch denies). mode: 'auto' (default) or 'authoritative'.
+ * May `subject` see `resource`, owned (says the product) by creator `owner`? { allow, reason, rule,
+ * entitlement }. `ruleId` pins the rule the product believes applies (a mismatch denies).
+ * mode: 'auto' (default) or 'authoritative'.
*/
*/
- async function evaluate({ subject, resource, ruleId = null, mode = 'auto', traceparent }) {
+ async function evaluate({ subject, resource, owner = null, ruleId = null, mode = 'auto', traceparent }) {
let r;
let r;
try { r = entityRef(resource); } catch { return deny('invalid_resource'); }
try { r = entityRef(resource); } catch { return deny('invalid_resource'); }
- const rule = ruleId ? byId(String(ruleId)) : forResource(r);
+ if (!owner) return deny('owner_required');
+ const oc = ownerCreator(owner);
+ if (!oc) return deny('no_rule');
+ const rule = ruleId ? byId(String(ruleId)) : forResource(r, oc.id);
if (!rule) return deny('no_rule');
if (!rule) return deny('no_rule');
if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });
if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });
+ if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: present(rule) });
if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });
if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });
const creator = creators.byId(rule.creator_id);
const creator = creators.byId(rule.creator_id);
if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });
if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });
@@ -93,7 +108,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
@@ -93,7 +108,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
return { allow: true, reason: 'member', rule: present(rule), entitlement: ent };
return { allow: true, reason: 'member', rule: present(rule), entitlement: ent };
}
}
- return { byId, forResource, set, disable, list, present, evaluate };
+ return { byId, forResource, ownerCreator, set, disable, list, present, evaluate };
}
}
module.exports = { createPolicies };
module.exports = { createPolicies };
@@ -17,7 +17,7 @@ const { test, run } = harness('policies');
@@ -17,7 +17,7 @@ const { test, run } = harness('policies');
const stranger = t.network.newUser('hal');
const stranger = t.network.newUser('hal');
const blog = { cap: ['vip.resource.policy.evaluate'], sub: 'svc:blog' };
const blog = { cap: ['vip.resource.policy.evaluate'], sub: 'svc:blog' };
const post = { service: 'blog', type: 'post', id: '42' };
const post = { service: 'blog', type: 'post', id: '42' };
- const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, ...extra } })).json;
+ const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, owner: creator.subject, ...extra } })).json;
let plan;
let plan;
await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Backstage posts', key: 'backstage', kind: 'gated_content', bindings: [{ product: 'blog', binding: 'gated_post' }] } });
await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Backstage posts', key: 'backstage', kind: 'gated_content', bindings: [{ product: 'blog', binding: 'gated_post' }] } });
@@ -26,6 +26,23 @@ const { test, run } = harness('policies');
@@ -26,6 +26,23 @@ const { test, run } = harness('policies');
await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { perks: [] } }); // v2 drops it
await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { perks: [] } }); // v2 drops it
await t.deliverAll(t.billing.pay(member.subject, creator.subject).events); // joins under v2
await t.deliverAll(t.billing.pay(member.subject, creator.subject).events); // joins under v2
+ test('a reference claimed first by another creator neither blocks the owner nor applies to them', async () => {
+ const squatter = t.network.newUser('sly', { role: 'streamer' });
+ const res = { service: 'blog', type: 'post', id: 'squat-1' };
+ const s1 = await t.call('POST', '/api/v1/policies', { user: squatter, body: { resource: res, requirement: 'member' } });
+ assert.strictEqual(s1.status, 201, s1.text);
+ const d0 = await evaluate(member.subject, res);
+ assert.strictEqual(d0.allow, false);
+ assert.strictEqual(d0.reason, 'no_rule', 'the squatter\'s rule is not the owner\'s');
+ const own = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'member' } });
+ assert.strictEqual(own.status, 201, 'the real owner is not blocked: ' + own.text);
+ assert.strictEqual((await evaluate(member.subject, res)).allow, true);
+ const pinned = await evaluate(member.subject, res, { rule_id: s1.json.rule.id });
+ assert.strictEqual(pinned.reason, 'owner_mismatch');
+ const noOwner = (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject: member.subject, resource: res } })).json;
+ assert.strictEqual(noOwner.reason, 'owner_required');
+ });
+
test('no rule for a resource → denied', async () => {
test('no rule for a resource → denied', async () => {
const d = await evaluate(member.subject);
const d = await evaluate(member.subject);
assert.deepStrictEqual([d.allow, d.reason], [false, 'no_rule']);
assert.deepStrictEqual([d.allow, d.reason], [false, 'no_rule']);
@@ -103,7 +120,7 @@ const { test, run } = harness('policies');
@@ -103,7 +120,7 @@ const { test, run } = harness('policies');
});
});
test('rule pinning, disabled rules, malformed resources and unknown rules all deny', async () => {
test('rule pinning, disabled rules, malformed resources and unknown rules all deny', async () => {
- const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42`, { cap: ['vip.resource.policy.get'] })).json.rule;
+ const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42&owner=${creator.subject}`, { cap: ['vip.resource.policy.get'] })).json.rule;
assert.ok(rule && rule.id);
assert.ok(rule && rule.id);
assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).allow, true);
assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).allow, true);
assert.strictEqual((await evaluate(member.subject, { service: 'blog', type: 'post', id: 'other' }, { rule_id: rule.id })).reason, 'rule_mismatch');
assert.strictEqual((await evaluate(member.subject, { service: 'blog', type: 'post', id: 'other' }, { rule_id: rule.id })).reason, 'rule_mismatch');
@@ -115,19 +132,22 @@ const { test, run } = harness('policies');
@@ -115,19 +132,22 @@ const { test, run } = harness('policies');
assert.strictEqual((await evaluate(member.subject, post)).reason, 'no_rule');
assert.strictEqual((await evaluate(member.subject, post)).reason, 'no_rule');
});
});
- test('only the creator sets rules; another creator cannot take over a gated resource', async () => {
+ test('only the creator sets rules; another creator\'s rule cannot take over a gated resource', async () => {
const res = { service: 'blog', type: 'post', id: 'mine' };
const res = { service: 'blog', type: 'post', id: 'mine' };
await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });
await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });
const other = t.network.newUser('kim', { role: 'streamer' });
const other = t.network.newUser('kim', { role: 'streamer' });
+ // Another creator's rule for the same reference is theirs alone: it never applies when the
+ // product names the real owner, so it cannot take the resource over.
const r = await t.call('POST', '/api/v1/policies', { user: other, body: { resource: res } });
const r = await t.call('POST', '/api/v1/policies', { user: other, body: { resource: res } });
- assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual((await evaluate(member.subject, res)).allow, true, 'the owner\'s rule still decides');
const svcNo = await t.call('POST', '/api/v1/policies', { cap: ['vip.resource.policy.evaluate'], body: { creator: { type: 'user', id: creator.subject }, resource: res } });
const svcNo = await t.call('POST', '/api/v1/policies', { cap: ['vip.resource.policy.evaluate'], body: { creator: { type: 'user', id: creator.subject }, resource: res } });
assert.strictEqual(svcNo.status, 403);
assert.strictEqual(svcNo.status, 403);
const evalNo = await t.call('POST', '/api/v1/policies/evaluate', { cap: ['vip.entitlement.check'], body: { subject: member.subject, resource: res } });
const evalNo = await t.call('POST', '/api/v1/policies/evaluate', { cap: ['vip.entitlement.check'], body: { subject: member.subject, resource: res } });
assert.strictEqual(evalNo.status, 403);
assert.strictEqual(evalNo.status, 403);
- const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res } });
+ const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res, owner: creator.subject } });
assert.strictEqual(self.json.allow, true);
assert.strictEqual(self.json.allow, true);
- const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res } });
+ const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res, owner: creator.subject } });
assert.strictEqual(spy.status, 403);
assert.strictEqual(spy.status, 403);
});
});
@@ -135,20 +155,20 @@ const { test, run } = harness('policies');
@@ -135,20 +155,20 @@ const { test, run } = harness('policies');
const res = { service: 'blog', type: 'post', id: 'mine' };
const res = { service: 'blog', type: 'post', id: 'mine' };
const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };
const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };
const vip = createVipClient({ baseUrl: t.base, tokenClient });
const vip = createVipClient({ baseUrl: t.base, tokenClient });
- assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res })).allow, true);
- assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res })).allow, false);
+ assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, true);
+ assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res, owner: creator.subject })).allow, false);
assert.strictEqual(await vip.isMember(member.subject, creator.subject), true);
assert.strictEqual(await vip.isMember(member.subject, creator.subject), true);
assert.strictEqual(await vip.isMember(stranger.subject, creator.subject), false);
assert.strictEqual(await vip.isMember(stranger.subject, creator.subject), false);
const down = createVipClient({ baseUrl: 'http://127.0.0.1:9', tokenClient, timeoutMs: 500 });
const down = createVipClient({ baseUrl: 'http://127.0.0.1:9', tokenClient, timeoutMs: 500 });
- const d = await down.evaluate({ subject: member.subject, resource: res });
+ const d = await down.evaluate({ subject: member.subject, resource: res, owner: creator.subject });
assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);
assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);
assert.strictEqual(await down.isMember(member.subject, creator.subject), false);
assert.strictEqual(await down.isMember(member.subject, creator.subject), false);
const noCap = createVipClient({ baseUrl: t.base, tokenClient: { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: [] })}` }) } });
const noCap = createVipClient({ baseUrl: t.base, tokenClient: { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: [] })}` }) } });
- assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res })).allow, false);
+ assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, false);
const badToken = createVipClient({ baseUrl: t.base, getToken: async () => 'not-a-token' });
const badToken = createVipClient({ baseUrl: t.base, getToken: async () => 'not-a-token' });
- assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res })).allow, false);
+ assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, false);
const tokenFails = createVipClient({ baseUrl: t.base, getToken: async () => { throw new Error('no grant'); } });
const tokenFails = createVipClient({ baseUrl: t.base, getToken: async () => { throw new Error('no grant'); } });
assert.strictEqual((await tokenFails.checkEntitlement({ subject: member.subject, creator: creator.subject })).status, 'unknown');
assert.strictEqual((await tokenFails.checkEntitlement({ subject: member.subject, creator: creator.subject })).status, 'unknown');
const garbage = createVipClient({ baseUrl: t.base, tokenClient, fetch: async () => new Response('{"allow":"yes"}', { status: 200 }) });
const garbage = createVipClient({ baseUrl: t.base, tokenClient, fetch: async () => new Response('{"allow":"yes"}', { status: 200 }) });