openvibers/openvibe.network Commits
Resource index: an owner filter (a person's own projects and nodes) (#133)
Changes
@@@ -2,7 +2,7 @@/*** Network's authority resource index (ADR-048 section 3; capability network.resource.read):*-- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1++ * GET /api/v1/resources[?project=&owner=&kind=&cursor=&limit=] → common.resource-list-result@1* GET /api/v1/resources/:ovrn → common.resource-summary@1** It pages the resources OpenVibe.Network owns — its developer projects (prj_), their apps (app_) and@@@ -33,6 +33,7 @@ const NODE_KIND = 'network.node';const KINDS = [PROJECT_KIND, APP_KIND, NODE_KIND];const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const OWNER_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;const DEFAULT_LIMIT = 100;const MAX_LIMIT = 1000;@@@ -87,28 +88,37 @@ function named(summary) {/** A stable (kind, id) ordering, so a cursor can be a position in it. */const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);--/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */++/** The summaries matching the project, owner and kind filters, sorted by (kind, id). */// Scope: network.resource.read is first-party (resourceConstraints none), so its holder sees every project and// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that// principal's grants here instead of trusting the query.--async function collect(db, { project = null, kind = null } = {}) {++async function collect(db, { project = null, owner = null, kind = null } = {}) {const out = [];++ if (owner && !USER_SUBJECT_RE.test(owner)) return out;if (!kind || kind === PROJECT_KIND) {-- const rows = project-- ? await db.prepare('SELECT * FROM dev_projects WHERE id = ?').all(project)-- : await db.prepare('SELECT * FROM dev_projects ORDER BY id').all();++ const rows = project && owner++ ? await db.prepare('SELECT * FROM dev_projects WHERE id = ? AND owner_subject = ? ORDER BY id').all(project, owner)++ : project++ ? await db.prepare('SELECT * FROM dev_projects WHERE id = ? ORDER BY id').all(project)++ : owner++ ? await db.prepare('SELECT * FROM dev_projects WHERE owner_subject = ? ORDER BY id').all(owner)++ : await db.prepare('SELECT * FROM dev_projects ORDER BY id').all();for (const p of rows) out.push(named(projectSummary(p)));}-- if (!kind || kind === APP_KIND) {++ if (!owner && (!kind || kind === APP_KIND)) {const rows = project? await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project): await db.prepare('SELECT * FROM dev_apps ORDER BY id').all();for (const a of rows) out.push(named(appSummary(a)));}if (!kind || kind === NODE_KIND) {-- const rows = project-- ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? ORDER BY id').all(project)-- : await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all();++ const rows = project && owner++ ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? AND owner_subject = ? ORDER BY id').all(project, owner)++ : project++ ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? ORDER BY id').all(project)++ : owner++ ? await db.prepare('SELECT * FROM platform_node_principals WHERE owner_subject = ? ORDER BY id').all(owner)++ : await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all();for (const n of rows) out.push(named(nodeSummary(n)));}return out.sort(order);@@@ -127,6 +137,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.idfunction filtersOf(query) {const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };++ const owner = query.owner === undefined || query.owner === '' ? null : query.owner;++ if (owner !== null && (typeof owner !== 'string' || !OWNER_ID_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;let limit = DEFAULT_LIMIT;if (typeof query.limit === 'string' && query.limit !== '') {@@@ -138,7 +150,7 @@ function filtersOf(query) {cursor = decodeCursor(query.cursor);if (!cursor) return { error: 'cursor is not one this index issued' };}-- return { project, kind, limit, cursor };++ return { project, owner, kind, limit, cursor };}function router({ guard }) {@@@ -2,7 +2,7 @@// Network's authority resource index (ADR-048 section 3; capability network.resource.read):// GET /api/v1/resources pages common.resource-summary@1 for the resources Network owns - its developer// projects (prj_), their apps (app_) and its node principals (nod_) - and GET /api/v1/resources/:ovrn--// reads one by its computed ovrn. ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy++// reads one by its computed ovrn. ?project=&owner=&kind=&cursor=&limit= are honoured; ?project= is the tenancy// boundary, so a resource of another project is never returned. An ovrn is present exactly when// openvibe-contracts' contracts.resources.nameOf composes one: never for a project (ADR-048: a project// is addressed by its bare prj_ id), never for an app (app_ is excluded from the OVRN id pattern), and@@@ -103,6 +103,31 @@ const resourceIndex = require('../server/registry/resource-index');assert.deepStrictEqual(await aIds('?kind=network.project'), [prjA, prjB].sort(), 'kind = project lists the projects');assert.deepStrictEqual(await aIds('?kind=network.unknown'), [], 'an unknown kind is an empty page, not an error');assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');++ assert.deepStrictEqual(await aIds('?owner='), expected.map((r) => r.id), 'an empty owner leaves the index unfiltered');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}`), [nodU, prjA], 'an owner sees only its project and user-owned node');++ assert.deepStrictEqual(await aIds(`?owner=${usrB}`), [prjB], 'another owner sees only its own rows');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.node`), [nodU], 'owner combines with kind');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.app`), [], 'apps have no summary owner');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjA}`), [prjA], 'owner combines with project');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjB}`), [], 'another project is excluded');++ assert.deepStrictEqual(await aIds(`?owner=agt_${'0'.repeat(26)}`), [], 'a valid agent id has no matching rows');++++ const ownerSeen = [];++ let ownerCursor = null;++ for (;;) {++ const y = await get(`/api/v1/resources?owner=${usrA}&limit=1${ownerCursor ? `&cursor=${encodeURIComponent(ownerCursor)}` : ''}`, auth);++ assert.strictEqual(y.status, 200);++ assert.ok(validate('common.resource-list-result@1', y.body).valid);++ y.body.resources.forEach((s) => {++ assert.ok(validate('common.resource-summary@1', s).valid, s.id);++ assert.strictEqual(s.owner.id, usrA);++ ownerSeen.push(s.id);++ });++ if (y.body.next_cursor === null) break;++ ownerCursor = y.body.next_cursor;++ if (ownerSeen.length > 2) assert.fail('the owner cursor chain never ended');++ }++ assert.deepStrictEqual(ownerSeen, [nodU, prjA], 'owner paging visits each row exactly once in (kind, id) order');const seen = [];let cursor = null;@@@ -141,7 +166,7 @@ const resourceIndex = require('../server/registry/resource-index');assert.strictEqual(y.body.code, 'resources.unknown_resource', why);}-- const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];++ const bad = [['?project=nope', 'project not a prj_ id'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];for (const [q, why] of bad) {const y = await get(`/api/v1/resources${q}`, auth);assert.strictEqual(y.status, 400, why);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Network's authority resource index (ADR-048 section 3; capability network.resource.read):
* Network's authority resource index (ADR-048 section 3; capability network.resource.read):
*
*
- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1
+ * GET /api/v1/resources[?project=&owner=&kind=&cursor=&limit=] → common.resource-list-result@1
* GET /api/v1/resources/:ovrn → common.resource-summary@1
* GET /api/v1/resources/:ovrn → common.resource-summary@1
*
*
* It pages the resources OpenVibe.Network owns — its developer projects (prj_), their apps (app_) and
* It pages the resources OpenVibe.Network owns — its developer projects (prj_), their apps (app_) and
@@ -33,6 +33,7 @@ const NODE_KIND = 'network.node';
@@ -33,6 +33,7 @@ const NODE_KIND = 'network.node';
const KINDS = [PROJECT_KIND, APP_KIND, NODE_KIND];
const KINDS = [PROJECT_KIND, APP_KIND, NODE_KIND];
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const OWNER_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const MAX_LIMIT = 1000;
@@ -87,28 +88,37 @@ function named(summary) {
@@ -87,28 +88,37 @@ function named(summary) {
/** A stable (kind, id) ordering, so a cursor can be a position in it. */
/** A stable (kind, id) ordering, so a cursor can be a position in it. */
const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
-/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
+/** The summaries matching the project, owner and kind filters, sorted by (kind, id). */
// Scope: network.resource.read is first-party (resourceConstraints none), so its holder sees every project and
// Scope: network.resource.read is first-party (resourceConstraints none), so its holder sees every project and
// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
// principal's grants here instead of trusting the query.
// principal's grants here instead of trusting the query.
-async function collect(db, { project = null, kind = null } = {}) {
+async function collect(db, { project = null, owner = null, kind = null } = {}) {
const out = [];
const out = [];
+ if (owner && !USER_SUBJECT_RE.test(owner)) return out;
if (!kind || kind === PROJECT_KIND) {
if (!kind || kind === PROJECT_KIND) {
- const rows = project
- ? await db.prepare('SELECT * FROM dev_projects WHERE id = ?').all(project)
- : await db.prepare('SELECT * FROM dev_projects ORDER BY id').all();
+ const rows = project && owner
+ ? await db.prepare('SELECT * FROM dev_projects WHERE id = ? AND owner_subject = ? ORDER BY id').all(project, owner)
+ : project
+ ? await db.prepare('SELECT * FROM dev_projects WHERE id = ? ORDER BY id').all(project)
+ : owner
+ ? await db.prepare('SELECT * FROM dev_projects WHERE owner_subject = ? ORDER BY id').all(owner)
+ : await db.prepare('SELECT * FROM dev_projects ORDER BY id').all();
for (const p of rows) out.push(named(projectSummary(p)));
for (const p of rows) out.push(named(projectSummary(p)));
}
}
- if (!kind || kind === APP_KIND) {
+ if (!owner && (!kind || kind === APP_KIND)) {
const rows = project
const rows = project
? await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project)
? await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project)
: await db.prepare('SELECT * FROM dev_apps ORDER BY id').all();
: await db.prepare('SELECT * FROM dev_apps ORDER BY id').all();
for (const a of rows) out.push(named(appSummary(a)));
for (const a of rows) out.push(named(appSummary(a)));
}
}
if (!kind || kind === NODE_KIND) {
if (!kind || kind === NODE_KIND) {
- const rows = project
- ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? ORDER BY id').all(project)
- : await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all();
+ const rows = project && owner
+ ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? AND owner_subject = ? ORDER BY id').all(project, owner)
+ : project
+ ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? ORDER BY id').all(project)
+ : owner
+ ? await db.prepare('SELECT * FROM platform_node_principals WHERE owner_subject = ? ORDER BY id').all(owner)
+ : await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all();
for (const n of rows) out.push(named(nodeSummary(n)));
for (const n of rows) out.push(named(nodeSummary(n)));
}
}
return out.sort(order);
return out.sort(order);
@@ -127,6 +137,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id
@@ -127,6 +137,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id
function filtersOf(query) {
function filtersOf(query) {
const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
+ const owner = query.owner === undefined || query.owner === '' ? null : query.owner;
+ if (owner !== null && (typeof owner !== 'string' || !OWNER_ID_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
let limit = DEFAULT_LIMIT;
let limit = DEFAULT_LIMIT;
if (typeof query.limit === 'string' && query.limit !== '') {
if (typeof query.limit === 'string' && query.limit !== '') {
@@ -138,7 +150,7 @@ function filtersOf(query) {
@@ -138,7 +150,7 @@ function filtersOf(query) {
cursor = decodeCursor(query.cursor);
cursor = decodeCursor(query.cursor);
if (!cursor) return { error: 'cursor is not one this index issued' };
if (!cursor) return { error: 'cursor is not one this index issued' };
}
}
- return { project, kind, limit, cursor };
+ return { project, owner, kind, limit, cursor };
}
}
function router({ guard }) {
function router({ guard }) {
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// Network's authority resource index (ADR-048 section 3; capability network.resource.read):
// Network's authority resource index (ADR-048 section 3; capability network.resource.read):
// GET /api/v1/resources pages common.resource-summary@1 for the resources Network owns - its developer
// GET /api/v1/resources pages common.resource-summary@1 for the resources Network owns - its developer
// projects (prj_), their apps (app_) and its node principals (nod_) - and GET /api/v1/resources/:ovrn
// projects (prj_), their apps (app_) and its node principals (nod_) - and GET /api/v1/resources/:ovrn
-// reads one by its computed ovrn. ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
+// reads one by its computed ovrn. ?project=&owner=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
// boundary, so a resource of another project is never returned. An ovrn is present exactly when
// boundary, so a resource of another project is never returned. An ovrn is present exactly when
// openvibe-contracts' contracts.resources.nameOf composes one: never for a project (ADR-048: a project
// openvibe-contracts' contracts.resources.nameOf composes one: never for a project (ADR-048: a project
// is addressed by its bare prj_ id), never for an app (app_ is excluded from the OVRN id pattern), and
// is addressed by its bare prj_ id), never for an app (app_ is excluded from the OVRN id pattern), and
@@ -103,6 +103,31 @@ const resourceIndex = require('../server/registry/resource-index');
@@ -103,6 +103,31 @@ const resourceIndex = require('../server/registry/resource-index');
assert.deepStrictEqual(await aIds('?kind=network.project'), [prjA, prjB].sort(), 'kind = project lists the projects');
assert.deepStrictEqual(await aIds('?kind=network.project'), [prjA, prjB].sort(), 'kind = project lists the projects');
assert.deepStrictEqual(await aIds('?kind=network.unknown'), [], 'an unknown kind is an empty page, not an error');
assert.deepStrictEqual(await aIds('?kind=network.unknown'), [], 'an unknown kind is an empty page, not an error');
assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
+ assert.deepStrictEqual(await aIds('?owner='), expected.map((r) => r.id), 'an empty owner leaves the index unfiltered');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}`), [nodU, prjA], 'an owner sees only its project and user-owned node');
+ assert.deepStrictEqual(await aIds(`?owner=${usrB}`), [prjB], 'another owner sees only its own rows');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.node`), [nodU], 'owner combines with kind');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.app`), [], 'apps have no summary owner');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjA}`), [prjA], 'owner combines with project');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjB}`), [], 'another project is excluded');
+ assert.deepStrictEqual(await aIds(`?owner=agt_${'0'.repeat(26)}`), [], 'a valid agent id has no matching rows');
+
+ const ownerSeen = [];
+ let ownerCursor = null;
+ for (;;) {
+ const y = await get(`/api/v1/resources?owner=${usrA}&limit=1${ownerCursor ? `&cursor=${encodeURIComponent(ownerCursor)}` : ''}`, auth);
+ assert.strictEqual(y.status, 200);
+ assert.ok(validate('common.resource-list-result@1', y.body).valid);
+ y.body.resources.forEach((s) => {
+ assert.ok(validate('common.resource-summary@1', s).valid, s.id);
+ assert.strictEqual(s.owner.id, usrA);
+ ownerSeen.push(s.id);
+ });
+ if (y.body.next_cursor === null) break;
+ ownerCursor = y.body.next_cursor;
+ if (ownerSeen.length > 2) assert.fail('the owner cursor chain never ended');
+ }
+ assert.deepStrictEqual(ownerSeen, [nodU, prjA], 'owner paging visits each row exactly once in (kind, id) order');
const seen = [];
const seen = [];
let cursor = null;
let cursor = null;
@@ -141,7 +166,7 @@ const resourceIndex = require('../server/registry/resource-index');
@@ -141,7 +166,7 @@ const resourceIndex = require('../server/registry/resource-index');
assert.strictEqual(y.body.code, 'resources.unknown_resource', why);
assert.strictEqual(y.body.code, 'resources.unknown_resource', why);
}
}
- const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
+ const bad = [['?project=nope', 'project not a prj_ id'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
for (const [q, why] of bad) {
for (const [q, why] of bad) {
const y = await get(`/api/v1/resources${q}`, auth);
const y = await get(`/api/v1/resources${q}`, auth);
assert.strictEqual(y.status, 400, why);
assert.strictEqual(y.status, 400, why);