openvibers/openvibe.network Commits
Media namespaces and grants, Network's side (roadmap WS-G task 2): an app token's ns is [project_id, app.<project_id>.*], so the grant names the project's Media namespaces (app.<project_id> production, app.<project_id>.sandbox, and children below them) while services keying tenancy by project id keep working; Media reads an older [project_id] the same way. The default sandbox allowance lists media.object.list and media.object.delete (Media's list and delete verbs), left out until the pinned openvibe-contracts defines them as public and active, like events.app.* before 0.28.0. Tests: developer-defaults (ns, the verbs with and without the catalog), developer-projects (ns); docs/developer-projects.md
Changes
@@@ -44,7 +44,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.--- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); with an older release they would be left out. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.++- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `media.object.list`, `media.object.delete`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.- `GET /api/v1/projects/catalog` lists what could ever be granted.@@@ -70,7 +70,7 @@ Tokens are RS256 and last 5 minutes. They are signed with Network's key (JWKS at| `actor_type` | `app` || `aud` | `[audience]`, one audience per token || `cap` | approved grants for that audience ∩ allowance ∩ grantable now (or the requested `scope` subset) |--| `ns` | `[project_id]`. Tenancy in other services is keyed by project id (ADR-014). |++| `ns` | `[project_id, app.<project_id>.*]`. Tenancy in other services is keyed by project id (ADR-014). Media names the project's namespaces `app.<project_id>` (production) and `app.<project_id>.sandbox`, with children below them, and checks each verb (read, list, write, delete, transform) per namespace; it reads an older token's `[project_id]` as `app.<project_id>.*` (roadmap WS-G task 2). || `project_id` | `prj_<ULID>` (optional in the contract today; proposed as required for apps) || `env` | `sandbox` or `production` (same) || `on_behalf_of` | `usr_<ULID>` of the person who authorized the app (authorization code only) |@@@ -94,7 +94,7 @@ With the defaults above, this works with no staff action:1. Create a project (`POST /api/v1/projects`). You are the owner.2. Create a sandbox confidential app (`POST /:project/apps` with `environment: sandbox`). The secret is shown once.3. Request grants from the sandbox allowance (`POST /:project/apps/:app/grants`). An owner's or admin's request is approved at once. A developer's request waits for an owner or admin, who can approve it without staff.--4. `POST /oauth/token` with `grant_type=client_credentials` and `audience=openvibe.media` (or `openvibe.events`, `openvibe.tools`). The token has `env: sandbox`, `project_id` and `ns: [project_id]`.++4. `POST /oauth/token` with `grant_type=client_credentials` and `audience=openvibe.media` (or `openvibe.events`, `openvibe.tools`). The token has `env: sandbox`, `project_id` and `ns: [project_id, app.<project_id>.*]`.Production apps still need staff: the project's environment policy (`sandbox+production`) and its allowance.@@@ -192,7 +192,7 @@ Until the relay is turned on in an environment, nobody else receives these event| Env var | Default | Meaning ||---|---|---|| `DEV_SANDBOX_AUDIENCES` | unset: `openvibe.media,openvibe.events,openvibe.tools` | Audiences that accept sandbox tokens (comma list). Unset means the code default; an empty value means none. |--| `DEV_SANDBOX_ALLOWANCE` | unset: `media.object.upload,media.object.read,events.app.publish,events.app.read,events.app.subscribe,tools.job.create,tools.job.read,tools.job.cancel` | Public capabilities sandbox apps may hold without staff (comma list, filtered to public + active capabilities of the installed catalog). Unset means the code default; an empty value means none. |++| `DEV_SANDBOX_ALLOWANCE` | unset: `media.object.upload,media.object.read,media.object.list,media.object.delete,events.app.publish,events.app.read,events.app.subscribe,tools.job.create,tools.job.read,tools.job.cancel` | Public capabilities sandbox apps may hold without staff (comma list, filtered to public + active capabilities of the installed catalog). Unset means the code default; an empty value means none. || `OV_EVENTS_INTERNAL_URL` | unset (relay off) | OpenVibe.Events base URL for relaying developer-project events || `DEV_CREDENTIAL_OVERLAP_S` | 86400 | Default rotation overlap (0–604800) || `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with |@@@ -55,10 +55,12 @@ const DEFAULT_SANDBOX_AUDIENCES = Object.freeze(['openvibe.media', 'openvibe.eve* Capabilities every project's SANDBOX apps may hold without a staff decision when* DEV_SANDBOX_ALLOWANCE is unset. Only public, active capabilities of the installed contracts* catalog count: an id the catalog does not know (events.app.* before openvibe-contracts-- * v0.28.0) is left out until it does. Production apps never get these: their allowance is staff-set.++ * v0.28.0; media.object.list and media.object.delete, Media's list and delete verbs, before the++ * contracts release that adds them) is left out until it does. Production apps never get these:++ * their allowance is staff-set.*/const DEFAULT_SANDBOX_ALLOWANCE = Object.freeze([-- 'media.object.upload', 'media.object.read',++ 'media.object.upload', 'media.object.read', 'media.object.list', 'media.object.delete','events.app.publish', 'events.app.read', 'events.app.subscribe','tools.job.create', 'tools.job.read', 'tools.job.cancel', 'tools.tool.read', 'tools.tool.run',]);@@@ -10,8 +10,11 @@** Tokens are RS256, 5 minutes, shaped by identity.service-token-claims@1:* sub app:app_<ULID>, actor_type app, aud [audience], cap (approved grants ∩ allowance ∩ still-- * grantable, for that audience), ns [project_id], project_id, env sandbox|production,-- * on_behalf_of usr_<ULID> (authorization code only).++ * grantable, for that audience), ns [project_id, app.<project_id>.*], project_id,++ * env sandbox|production, on_behalf_of usr_<ULID> (authorization code only).++ * The ns: services key a project's tenancy by its id (ADR-014), and Media names a project's++ * namespaces app.<project_id> (production) and app.<project_id>.sandbox, with children below them++ * (roadmap WS-G task 2), so the grant covers exactly the project's namespaces in both.* A sandbox app gets a token only for an audience that opted in to sandbox tokens* (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.* No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end@@@ -49,6 +52,11 @@ function effectiveGrants(db, app, project, audience, settings) {.filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);}++/** The namespaces an app token names: its project, and the project's app.<project_id>.* namespaces. */++function projectNamespaces(projectId) {++ return [projectId, `app.${projectId}.*`];++}++function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {const aud = String(audience || '').trim();if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');@@@ -63,7 +71,7 @@ function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, isif (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);const now = Math.floor(Date.now() / 1000);const claims = {-- iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: [project.id],++ iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),project_id: project.id, env: app.environment,...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,@@@ -159,4 +167,4 @@ function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {return { code };}--module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, TOKEN_TTL_S };++module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S };@@@ -86,6 +86,17 @@ async function withoutCatalog(hidden, fn) {assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), [],'capabilities the installed contracts do not define are silently left out');});++ // Media's list and delete verbs (WS-G task 2) enter the default sandbox allowance once the pinned++ // contracts define them as public and active; until then they are left out like any unknown id.++ assert.ok(['media.object.list', 'media.object.delete'].every(id => policy.DEFAULT_SANDBOX_ALLOWANCE.includes(id)));++ const MEDIA_VERBS = ['media.object.list', 'media.object.delete'].map(id => ({ id, version: '1.0', owner: 'media', status: 'active', visibility: 'public', description: 'test', permissions: [], resourceConstraints: ['namespace'], events: [], implementedBy: [] }));++ await withoutCatalog(MEDIA_VERBS.map(c => c.id), () => withCatalog(MEDIA_VERBS, () => {++ const media = policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.'));++ assert.deepStrictEqual(media, ['media.object.delete', 'media.object.list', 'media.object.read', 'media.object.upload'], 'with the verbs in the catalog, sandbox apps may hold them');++ }));++ await withoutCatalog(['media.object.list', 'media.object.delete'], () => {++ assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.')), ['media.object.read', 'media.object.upload'], 'without them, as before');++ });await new Promise(r => server.listen(0, '127.0.0.1', r));const base = `http://127.0.0.1:${server.address().port}`;@@@ -122,7 +133,7 @@ async function withoutCatalog(hidden, fn) {let v = verify(t.body.access_token, 'openvibe.media');assert.ok(v.ok, v.reason);assert.strictEqual(v.claims.env, 'sandbox');-- assert.deepStrictEqual(v.claims.ns, [P]);++ assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project, and its app.<project_id>.* namespaces (Media, WS-G task 2)');assert.strictEqual(v.claims.project_id, P);assert.deepStrictEqual(v.claims.cap, ['media.object.read', 'media.object.upload']);assert.strictEqual(serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media' }).code, 'token.sandbox_refused',@@@ -178,7 +178,7 @@ const secretsSeen = [];assert.deepStrictEqual(v.claims.cap, ['media.object.upload'], 'only the approved grant');assert.strictEqual(v.claims.project_id, P);assert.strictEqual(v.claims.env, 'sandbox');-- assert.deepStrictEqual(v.claims.ns, [P]);++ assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project and its app.<project_id>.* namespaces');assert.strictEqual(v.claims.exp - v.claims.iat, 300);assert.ok(validate('identity.service-token-claims@1', v.claims).valid);r = await fetch(`${base}/api/v1/projects`, { headers: { authorization: `Bearer ${t.body.access_token}` } });
@@ -44,7 +44,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
@@ -44,7 +44,7 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.
- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.
- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.
- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.
-- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); with an older release they would be left out. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
+- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `media.object.list`, `media.object.delete`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.30.1); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.
- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.
- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.
- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.
- `GET /api/v1/projects/catalog` lists what could ever be granted.
- `GET /api/v1/projects/catalog` lists what could ever be granted.
@@ -70,7 +70,7 @@ Tokens are RS256 and last 5 minutes. They are signed with Network's key (JWKS at
@@ -70,7 +70,7 @@ Tokens are RS256 and last 5 minutes. They are signed with Network's key (JWKS at
| `actor_type` | `app` |
| `actor_type` | `app` |
| `aud` | `[audience]`, one audience per token |
| `aud` | `[audience]`, one audience per token |
| `cap` | approved grants for that audience ∩ allowance ∩ grantable now (or the requested `scope` subset) |
| `cap` | approved grants for that audience ∩ allowance ∩ grantable now (or the requested `scope` subset) |
-| `ns` | `[project_id]`. Tenancy in other services is keyed by project id (ADR-014). |
+| `ns` | `[project_id, app.<project_id>.*]`. Tenancy in other services is keyed by project id (ADR-014). Media names the project's namespaces `app.<project_id>` (production) and `app.<project_id>.sandbox`, with children below them, and checks each verb (read, list, write, delete, transform) per namespace; it reads an older token's `[project_id]` as `app.<project_id>.*` (roadmap WS-G task 2). |
| `project_id` | `prj_<ULID>` (optional in the contract today; proposed as required for apps) |
| `project_id` | `prj_<ULID>` (optional in the contract today; proposed as required for apps) |
| `env` | `sandbox` or `production` (same) |
| `env` | `sandbox` or `production` (same) |
| `on_behalf_of` | `usr_<ULID>` of the person who authorized the app (authorization code only) |
| `on_behalf_of` | `usr_<ULID>` of the person who authorized the app (authorization code only) |
@@ -94,7 +94,7 @@ With the defaults above, this works with no staff action:
@@ -94,7 +94,7 @@ With the defaults above, this works with no staff action:
1. Create a project (`POST /api/v1/projects`). You are the owner.
1. Create a project (`POST /api/v1/projects`). You are the owner.
2. Create a sandbox confidential app (`POST /:project/apps` with `environment: sandbox`). The secret is shown once.
2. Create a sandbox confidential app (`POST /:project/apps` with `environment: sandbox`). The secret is shown once.
3. Request grants from the sandbox allowance (`POST /:project/apps/:app/grants`). An owner's or admin's request is approved at once. A developer's request waits for an owner or admin, who can approve it without staff.
3. Request grants from the sandbox allowance (`POST /:project/apps/:app/grants`). An owner's or admin's request is approved at once. A developer's request waits for an owner or admin, who can approve it without staff.
-4. `POST /oauth/token` with `grant_type=client_credentials` and `audience=openvibe.media` (or `openvibe.events`, `openvibe.tools`). The token has `env: sandbox`, `project_id` and `ns: [project_id]`.
+4. `POST /oauth/token` with `grant_type=client_credentials` and `audience=openvibe.media` (or `openvibe.events`, `openvibe.tools`). The token has `env: sandbox`, `project_id` and `ns: [project_id, app.<project_id>.*]`.
Production apps still need staff: the project's environment policy (`sandbox+production`) and its allowance.
Production apps still need staff: the project's environment policy (`sandbox+production`) and its allowance.
@@ -192,7 +192,7 @@ Until the relay is turned on in an environment, nobody else receives these event
@@ -192,7 +192,7 @@ Until the relay is turned on in an environment, nobody else receives these event
| Env var | Default | Meaning |
| Env var | Default | Meaning |
|---|---|---|
|---|---|---|
| `DEV_SANDBOX_AUDIENCES` | unset: `openvibe.media,openvibe.events,openvibe.tools` | Audiences that accept sandbox tokens (comma list). Unset means the code default; an empty value means none. |
| `DEV_SANDBOX_AUDIENCES` | unset: `openvibe.media,openvibe.events,openvibe.tools` | Audiences that accept sandbox tokens (comma list). Unset means the code default; an empty value means none. |
-| `DEV_SANDBOX_ALLOWANCE` | unset: `media.object.upload,media.object.read,events.app.publish,events.app.read,events.app.subscribe,tools.job.create,tools.job.read,tools.job.cancel` | Public capabilities sandbox apps may hold without staff (comma list, filtered to public + active capabilities of the installed catalog). Unset means the code default; an empty value means none. |
+| `DEV_SANDBOX_ALLOWANCE` | unset: `media.object.upload,media.object.read,media.object.list,media.object.delete,events.app.publish,events.app.read,events.app.subscribe,tools.job.create,tools.job.read,tools.job.cancel` | Public capabilities sandbox apps may hold without staff (comma list, filtered to public + active capabilities of the installed catalog). Unset means the code default; an empty value means none. |
| `OV_EVENTS_INTERNAL_URL` | unset (relay off) | OpenVibe.Events base URL for relaying developer-project events |
| `OV_EVENTS_INTERNAL_URL` | unset (relay off) | OpenVibe.Events base URL for relaying developer-project events |
| `DEV_CREDENTIAL_OVERLAP_S` | 86400 | Default rotation overlap (0–604800) |
| `DEV_CREDENTIAL_OVERLAP_S` | 86400 | Default rotation overlap (0–604800) |
| `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with |
| `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with |
@@ -55,10 +55,12 @@ const DEFAULT_SANDBOX_AUDIENCES = Object.freeze(['openvibe.media', 'openvibe.eve
@@ -55,10 +55,12 @@ const DEFAULT_SANDBOX_AUDIENCES = Object.freeze(['openvibe.media', 'openvibe.eve
* Capabilities every project's SANDBOX apps may hold without a staff decision when
* Capabilities every project's SANDBOX apps may hold without a staff decision when
* DEV_SANDBOX_ALLOWANCE is unset. Only public, active capabilities of the installed contracts
* DEV_SANDBOX_ALLOWANCE is unset. Only public, active capabilities of the installed contracts
* catalog count: an id the catalog does not know (events.app.* before openvibe-contracts
* catalog count: an id the catalog does not know (events.app.* before openvibe-contracts
- * v0.28.0) is left out until it does. Production apps never get these: their allowance is staff-set.
+ * v0.28.0; media.object.list and media.object.delete, Media's list and delete verbs, before the
+ * contracts release that adds them) is left out until it does. Production apps never get these:
+ * their allowance is staff-set.
*/
*/
const DEFAULT_SANDBOX_ALLOWANCE = Object.freeze([
const DEFAULT_SANDBOX_ALLOWANCE = Object.freeze([
- 'media.object.upload', 'media.object.read',
+ 'media.object.upload', 'media.object.read', 'media.object.list', 'media.object.delete',
'events.app.publish', 'events.app.read', 'events.app.subscribe',
'events.app.publish', 'events.app.read', 'events.app.subscribe',
'tools.job.create', 'tools.job.read', 'tools.job.cancel', 'tools.tool.read', 'tools.tool.run',
'tools.job.create', 'tools.job.read', 'tools.job.cancel', 'tools.tool.read', 'tools.tool.run',
]);
]);
@@ -10,8 +10,11 @@
@@ -10,8 +10,11 @@
*
*
* Tokens are RS256, 5 minutes, shaped by identity.service-token-claims@1:
* Tokens are RS256, 5 minutes, shaped by identity.service-token-claims@1:
* sub app:app_<ULID>, actor_type app, aud [audience], cap (approved grants ∩ allowance ∩ still
* sub app:app_<ULID>, actor_type app, aud [audience], cap (approved grants ∩ allowance ∩ still
- * grantable, for that audience), ns [project_id], project_id, env sandbox|production,
- * on_behalf_of usr_<ULID> (authorization code only).
+ * grantable, for that audience), ns [project_id, app.<project_id>.*], project_id,
+ * env sandbox|production, on_behalf_of usr_<ULID> (authorization code only).
+ * The ns: services key a project's tenancy by its id (ADR-014), and Media names a project's
+ * namespaces app.<project_id> (production) and app.<project_id>.sandbox, with children below them
+ * (roadmap WS-G task 2), so the grant covers exactly the project's namespaces in both.
* A sandbox app gets a token only for an audience that opted in to sandbox tokens
* A sandbox app gets a token only for an audience that opted in to sandbox tokens
* (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.
* (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.
* No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end
* No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end
@@ -49,6 +52,11 @@ function effectiveGrants(db, app, project, audience, settings) {
@@ -49,6 +52,11 @@ function effectiveGrants(db, app, project, audience, settings) {
.filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);
.filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);
}
}
+/** The namespaces an app token names: its project, and the project's app.<project_id>.* namespaces. */
+function projectNamespaces(projectId) {
+ return [projectId, `app.${projectId}.*`];
+}
+
function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
const aud = String(audience || '').trim();
const aud = String(audience || '').trim();
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
@@ -63,7 +71,7 @@ function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, is
@@ -63,7 +71,7 @@ function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, is
if (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
if (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
const claims = {
const claims = {
- iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: [project.id],
+ iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
project_id: project.id, env: app.environment,
project_id: project.id, env: app.environment,
...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
@@ -159,4 +167,4 @@ function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
@@ -159,4 +167,4 @@ function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
return { code };
return { code };
}
}
-module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, TOKEN_TTL_S };
+module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S };
@@ -86,6 +86,17 @@ async function withoutCatalog(hidden, fn) {
@@ -86,6 +86,17 @@ async function withoutCatalog(hidden, fn) {
assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), [],
assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('events.')), [],
'capabilities the installed contracts do not define are silently left out');
'capabilities the installed contracts do not define are silently left out');
});
});
+ // Media's list and delete verbs (WS-G task 2) enter the default sandbox allowance once the pinned
+ // contracts define them as public and active; until then they are left out like any unknown id.
+ assert.ok(['media.object.list', 'media.object.delete'].every(id => policy.DEFAULT_SANDBOX_ALLOWANCE.includes(id)));
+ const MEDIA_VERBS = ['media.object.list', 'media.object.delete'].map(id => ({ id, version: '1.0', owner: 'media', status: 'active', visibility: 'public', description: 'test', permissions: [], resourceConstraints: ['namespace'], events: [], implementedBy: [] }));
+ await withoutCatalog(MEDIA_VERBS.map(c => c.id), () => withCatalog(MEDIA_VERBS, () => {
+ const media = policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.'));
+ assert.deepStrictEqual(media, ['media.object.delete', 'media.object.list', 'media.object.read', 'media.object.upload'], 'with the verbs in the catalog, sandbox apps may hold them');
+ }));
+ await withoutCatalog(['media.object.list', 'media.object.delete'], () => {
+ assert.deepStrictEqual(policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.')), ['media.object.read', 'media.object.upload'], 'without them, as before');
+ });
await new Promise(r => server.listen(0, '127.0.0.1', r));
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -122,7 +133,7 @@ async function withoutCatalog(hidden, fn) {
@@ -122,7 +133,7 @@ async function withoutCatalog(hidden, fn) {
let v = verify(t.body.access_token, 'openvibe.media');
let v = verify(t.body.access_token, 'openvibe.media');
assert.ok(v.ok, v.reason);
assert.ok(v.ok, v.reason);
assert.strictEqual(v.claims.env, 'sandbox');
assert.strictEqual(v.claims.env, 'sandbox');
- assert.deepStrictEqual(v.claims.ns, [P]);
+ assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project, and its app.<project_id>.* namespaces (Media, WS-G task 2)');
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.project_id, P);
assert.deepStrictEqual(v.claims.cap, ['media.object.read', 'media.object.upload']);
assert.deepStrictEqual(v.claims.cap, ['media.object.read', 'media.object.upload']);
assert.strictEqual(serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media' }).code, 'token.sandbox_refused',
assert.strictEqual(serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media' }).code, 'token.sandbox_refused',
@@ -178,7 +178,7 @@ const secretsSeen = [];
@@ -178,7 +178,7 @@ const secretsSeen = [];
assert.deepStrictEqual(v.claims.cap, ['media.object.upload'], 'only the approved grant');
assert.deepStrictEqual(v.claims.cap, ['media.object.upload'], 'only the approved grant');
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.env, 'sandbox');
assert.strictEqual(v.claims.env, 'sandbox');
- assert.deepStrictEqual(v.claims.ns, [P]);
+ assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project and its app.<project_id>.* namespaces');
assert.strictEqual(v.claims.exp - v.claims.iat, 300);
assert.strictEqual(v.claims.exp - v.claims.iat, 300);
assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
r = await fetch(`${base}/api/v1/projects`, { headers: { authorization: `Bearer ${t.body.access_token}` } });
r = await fetch(`${base}/api/v1/projects`, { headers: { authorization: `Bearer ${t.body.access_token}` } });