openvibers/openvibe.reviews Commits
Staff powers from the contracts staff map (WS-D, ADR-022; Contracts 0.38.0): staff editors are staff.editorial.manage (admin; global_mods moderate but no longer edit entities), not a role list; REVIEWS_EDITORS stays the product's own editor role. test/staff-map.test.js fails on raw role checks
Changes
@@@ -14,7 +14,7 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"@@@ -999,9 +999,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",-- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",++ "version": "0.38.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",++ "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -19,7 +19,7 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"@@@ -5,7 +5,7 @@* { kind: 'anonymous', subject: null }* { kind: 'user', subject: 'usr_…'|null, staff, user }* A browser or client with the Network user JWT (ov_token cookie or Bearer), verified offline-- * (RS256, issuer, audience). role admin/global_mod makes the person staff.++ * (RS256, issuer, audience). staff = the contracts staff map's staff.editorial.manage (ADR-022).* { kind: 'service', service: 'svc:x', claims, subject }* A Network client-credentials token for audience openvibe.reviews. It names the person it acts* for in X-OV-Subject (usr_…); every route checks one capability against the token.@@@ -18,8 +18,7 @@const contracts = require('openvibe-contracts');const { verifyUserToken } = require('openvibe-sdk/auth');--const { ids, serviceAuth } = contracts;--const STAFF_ROLES = new Set(['admin', 'global_mod']);++const { ids, serviceAuth, staff: staffMap } = contracts;const PRINCIPAL_SUB = /^(svc|app|mod):/;const ACCESS_COOKIE = 'ov_token';@@@ -83,7 +82,7 @@ function createViewerResolver({ keys, config }) {}const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;return {-- kind: 'user', subject, staff: STAFF_ROLES.has(claims.role),++ kind: 'user', subject, staff: staffMap.can(claims, 'staff.editorial.manage'),user: { username: claims.username || null, display_name: claims.display_name || claims.username || null, avatar_url: claims.avatar_url || null, role: claims.role || null, subject_id: subject },};}@@@ -110,4 +109,4 @@ function createViewerResolver({ keys, config }) {return { resolve };}--module.exports = { createViewerResolver, AuthError, ANONYMOUS, ACCESS_COOKIE, STAFF_ROLES };++module.exports = { createViewerResolver, AuthError, ANONYMOUS, ACCESS_COOKIE };@@@ -82,8 +82,10 @@ t('API identity: bad tokens are refused, never downgraded; editor actions need aassert.strictEqual(svc.status, 403);const asEditor = await req(h, 'POST', '/api/v1/entities', { token: serviceToken({ cap: ['reviews.entity.manage'] }), headers: { 'X-OV-Subject': require('./helpers').EDITOR }, body: { name: 'Made For An Editor', kind: 'software' } });assert.strictEqual(asEditor.status, 201, asEditor.text);-- const staff = await req(h, 'POST', '/api/v1/entities', { token: require('./helpers').userToken({ role: 'global_mod' }), body: { name: 'Made By Staff' } });-- assert.strictEqual(staff.status, 201);++ const staff = await req(h, 'POST', '/api/v1/entities', { token: require('./helpers').userToken({ role: 'admin' }), body: { name: 'Made By Staff' } });++ assert.strictEqual(staff.status, 201, 'staff.editorial.manage (admin) edits');++ const mod = await req(h, 'POST', '/api/v1/entities', { token: require('./helpers').userToken({ role: 'global_mod' }), body: { name: 'Made By A Mod' } });++ assert.strictEqual(mod.status, 403, 'a global_mod moderates, it does not edit');const ready = await req(h, 'GET', '/api/ready');assert.strictEqual(ready.status, 200);assert.strictEqual(ready.json.ready, true);nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.editorial.manage makes a person an editor (REVIEWS_EDITORS names more).++// No server file compares a person's role by hand.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { staff } = require('openvibe-contracts');++++const CAPS = ["staff.editorial.manage"];++for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [false]], [{"role": "admin"}, [true]], [{"role": "admin", "is_owner": true}, [true]]]) {++ CAPS.forEach((cap, i) => assert.strictEqual(staff.can(claims, cap), want[i], `${JSON.stringify(claims)} ${cap}`));++}++++const offenders = [];++(function walk(dir) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const f = path.join(dir, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(f); continue; }++ if (!/\.(js|ts)$/.test(e.name)) continue;++ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {++ if (/STAFF_ROLES|STAFF_SUBJECTS|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) offenders.push(`${path.relative(path.join(__dirname, '..'), f)}:${i + 1}`);++ });++ }++})(path.join(__dirname, '..', "server"));++assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');++console.log('staff map: all checks passed');
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
@@ -999,9 +999,9 @@
@@ -999,9 +999,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
+ "version": "0.38.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
+ "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* { kind: 'anonymous', subject: null }
* { kind: 'anonymous', subject: null }
* { kind: 'user', subject: 'usr_…'|null, staff, user }
* { kind: 'user', subject: 'usr_…'|null, staff, user }
* A browser or client with the Network user JWT (ov_token cookie or Bearer), verified offline
* A browser or client with the Network user JWT (ov_token cookie or Bearer), verified offline
- * (RS256, issuer, audience). role admin/global_mod makes the person staff.
+ * (RS256, issuer, audience). staff = the contracts staff map's staff.editorial.manage (ADR-022).
* { kind: 'service', service: 'svc:x', claims, subject }
* { kind: 'service', service: 'svc:x', claims, subject }
* A Network client-credentials token for audience openvibe.reviews. It names the person it acts
* A Network client-credentials token for audience openvibe.reviews. It names the person it acts
* for in X-OV-Subject (usr_…); every route checks one capability against the token.
* for in X-OV-Subject (usr_…); every route checks one capability against the token.
@@ -18,8 +18,7 @@
@@ -18,8 +18,7 @@
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { verifyUserToken } = require('openvibe-sdk/auth');
-const { ids, serviceAuth } = contracts;
-const STAFF_ROLES = new Set(['admin', 'global_mod']);
+const { ids, serviceAuth, staff: staffMap } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
@@ -83,7 +82,7 @@ function createViewerResolver({ keys, config }) {
@@ -83,7 +82,7 @@ function createViewerResolver({ keys, config }) {
}
}
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
return {
return {
- kind: 'user', subject, staff: STAFF_ROLES.has(claims.role),
+ kind: 'user', subject, staff: staffMap.can(claims, 'staff.editorial.manage'),
user: { username: claims.username || null, display_name: claims.display_name || claims.username || null, avatar_url: claims.avatar_url || null, role: claims.role || null, subject_id: subject },
user: { username: claims.username || null, display_name: claims.display_name || claims.username || null, avatar_url: claims.avatar_url || null, role: claims.role || null, subject_id: subject },
};
};
}
}
@@ -110,4 +109,4 @@ function createViewerResolver({ keys, config }) {
@@ -110,4 +109,4 @@ function createViewerResolver({ keys, config }) {
return { resolve };
return { resolve };
}
}
-module.exports = { createViewerResolver, AuthError, ANONYMOUS, ACCESS_COOKIE, STAFF_ROLES };
+module.exports = { createViewerResolver, AuthError, ANONYMOUS, ACCESS_COOKIE };
@@ -82,8 +82,10 @@ t('API identity: bad tokens are refused, never downgraded; editor actions need a
@@ -82,8 +82,10 @@ t('API identity: bad tokens are refused, never downgraded; editor actions need a
assert.strictEqual(svc.status, 403);
assert.strictEqual(svc.status, 403);
const asEditor = await req(h, 'POST', '/api/v1/entities', { token: serviceToken({ cap: ['reviews.entity.manage'] }), headers: { 'X-OV-Subject': require('./helpers').EDITOR }, body: { name: 'Made For An Editor', kind: 'software' } });
const asEditor = await req(h, 'POST', '/api/v1/entities', { token: serviceToken({ cap: ['reviews.entity.manage'] }), headers: { 'X-OV-Subject': require('./helpers').EDITOR }, body: { name: 'Made For An Editor', kind: 'software' } });
assert.strictEqual(asEditor.status, 201, asEditor.text);
assert.strictEqual(asEditor.status, 201, asEditor.text);
- const staff = await req(h, 'POST', '/api/v1/entities', { token: require('./helpers').userToken({ role: 'global_mod' }), body: { name: 'Made By Staff' } });
- assert.strictEqual(staff.status, 201);
+ const staff = await req(h, 'POST', '/api/v1/entities', { token: require('./helpers').userToken({ role: 'admin' }), body: { name: 'Made By Staff' } });
+ assert.strictEqual(staff.status, 201, 'staff.editorial.manage (admin) edits');
+ const mod = await req(h, 'POST', '/api/v1/entities', { token: require('./helpers').userToken({ role: 'global_mod' }), body: { name: 'Made By A Mod' } });
+ assert.strictEqual(mod.status, 403, 'a global_mod moderates, it does not edit');
const ready = await req(h, 'GET', '/api/ready');
const ready = await req(h, 'GET', '/api/ready');
assert.strictEqual(ready.status, 200);
assert.strictEqual(ready.status, 200);
assert.strictEqual(ready.json.ready, true);
assert.strictEqual(ready.json.ready, true);
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.editorial.manage makes a person an editor (REVIEWS_EDITORS names more).
+// No server file compares a person's role by hand.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { staff } = require('openvibe-contracts');
+
+const CAPS = ["staff.editorial.manage"];
+for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [false]], [{"role": "admin"}, [true]], [{"role": "admin", "is_owner": true}, [true]]]) {
+ CAPS.forEach((cap, i) => assert.strictEqual(staff.can(claims, cap), want[i], `${JSON.stringify(claims)} ${cap}`));
+}
+
+const offenders = [];
+(function walk(dir) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const f = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(f); continue; }
+ if (!/\.(js|ts)$/.test(e.name)) continue;
+ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
+ if (/STAFF_ROLES|STAFF_SUBJECTS|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) offenders.push(`${path.relative(path.join(__dirname, '..'), f)}:${i + 1}`);
+ });
+ }
+})(path.join(__dirname, '..', "server"));
+assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');
+console.log('staff map: all checks passed');