Skip to content

Commit d2f9309

Browse files
bosschaertclaude
andcommitted
feat: add per-site /{site}/CONFIG permission for site config
Introduce a per-site config permission keyword `/{site}/CONFIG` that governs reading/writing site config (/config/{org}/{site}/...), mirroring how the org-level `CONFIG` keyword governs org config (/config/{org}). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 24f9aa3 commit d2f9309

4 files changed

Lines changed: 124 additions & 5 deletions

File tree

src/routes/config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@
1212

1313
import putKv from '../storage/kv/put.js';
1414
import getKv from '../storage/kv/get.js';
15-
import { hasPermission } from '../utils/auth.js';
15+
import { configPermissionPath, hasPermission } from '../utils/auth.js';
1616

1717
export async function postConfig({ req, env, daCtx }) {
18-
if (!hasPermission(daCtx, 'CONFIG', 'write', true)) {
18+
if (!hasPermission(daCtx, configPermissionPath(daCtx), 'write', true)) {
1919
return { status: 403 };
2020
}
2121

2222
return putKv(req, env, daCtx);
2323
}
2424

2525
export async function getConfig({ env, daCtx }) {
26-
if (!hasPermission(daCtx, 'CONFIG', 'read', true)) {
26+
if (!hasPermission(daCtx, configPermissionPath(daCtx), 'read', true)) {
2727
return { status: 403 };
2828
}
2929

src/utils/auth.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ export function pathSorter({ path: path1 }, { path: path2 }) {
228228
return sp2.length - sp1.length;
229229
}
230230

231+
/**
232+
* The keyword path that governs access to a config resource. Org-level config
233+
* (`/config/{org}`) is governed by the `CONFIG` keyword, while site-level config
234+
* (`/config/{org}/{site}/...`) is governed by a per-site `/{site}/CONFIG` keyword.
235+
* The `CONFIG` portion is always uppercase so it cannot collide with a content path.
236+
*/
237+
export function configPermissionPath(daCtx) {
238+
return daCtx.site ? `/${daCtx.site}/CONFIG` : 'CONFIG';
239+
}
240+
231241
export async function getAclCtx(env, org, users, key, api) {
232242
const pathLookup = new Map();
233243

@@ -328,7 +338,10 @@ export async function getAclCtx(env, org, users, key, api) {
328338
// Do a lookup for the base key, we always need this info
329339
let k;
330340
if (api === 'config') {
331-
k = 'CONFIG';
341+
// Org config (`/config/{org}`) is governed by the `CONFIG` keyword. Site config
342+
// (`/config/{org}/{site}/...`) is governed by a per-site `/{site}/CONFIG` keyword.
343+
const [site] = key.split('/').filter((part) => part.length > 0);
344+
k = site ? `/${site}/CONFIG` : 'CONFIG';
332345
} else {
333346
k = key.startsWith('/') ? key : `/${key}`;
334347
}
@@ -354,7 +367,7 @@ export async function getAclCtx(env, org, users, key, api) {
354367
? actionTrace
355368
: undefined;
356369

357-
if (k === 'CONFIG' || api === 'versionsource') {
370+
if (api === 'config' || api === 'versionsource') {
358371
actionSet.add('read');
359372
}
360373

test/routes/config.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,61 @@ describe('Config', () => {
7575
assert.deepStrictEqual(getKVCalled, [{ e: env, c: ctx }]);
7676
});
7777

78+
it('Test postConfig site config uses /{site}/CONFIG permission', async () => {
79+
const ctx = { site: 'mysite' };
80+
const env = {};
81+
const req = {};
82+
83+
const putKVCalled = [];
84+
const putKV = async (r, q, c) => {
85+
putKVCalled.push({ r, q, c });
86+
return 'called';
87+
};
88+
89+
const hasPermission = (c, k, a, kw) => k === '/mysite/CONFIG' && a === 'write' && kw === true;
90+
91+
const { postConfig } = await esmock('../../src/routes/config.js', {
92+
'../../src/storage/kv/put.js': {
93+
default: putKV,
94+
},
95+
'../../src/utils/auth.js': {
96+
hasPermission,
97+
configPermissionPath: (c) => `/${c.site}/CONFIG`,
98+
},
99+
});
100+
101+
const res = await postConfig({ req, env, daCtx: ctx });
102+
assert.strictEqual(res, 'called');
103+
assert.deepStrictEqual(putKVCalled, [{ r: req, q: env, c: ctx }]);
104+
});
105+
106+
it('Test getConfig site config uses /{site}/CONFIG permission', async () => {
107+
const ctx = { site: 'mysite' };
108+
const env = {};
109+
110+
const getKVCalled = [];
111+
const getKV = async (e, c) => {
112+
getKVCalled.push({ e, c });
113+
return 'called';
114+
};
115+
116+
const hasPermission = (c, k, a, kw) => k === '/mysite/CONFIG' && a === 'read' && kw === true;
117+
118+
const { getConfig } = await esmock('../../src/routes/config.js', {
119+
'../../src/storage/kv/get.js': {
120+
default: getKV,
121+
},
122+
'../../src/utils/auth.js': {
123+
hasPermission,
124+
configPermissionPath: (c) => `/${c.site}/CONFIG`,
125+
},
126+
});
127+
128+
const res = await getConfig({ env, daCtx: ctx });
129+
assert.strictEqual(res, 'called');
130+
assert.deepStrictEqual(getKVCalled, [{ e: env, c: ctx }]);
131+
});
132+
78133
it('Test no permission', async () => {
79134
const ctx = {};
80135
const env = {};

test/utils/auth.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import env from './mocks/env.js';
1919
import jose from './mocks/jose.js';
2020
import fetch from './mocks/fetch.js';
2121
import {
22+
configPermissionPath,
2223
getAclCtx,
2324
getChildRules,
2425
getUserActions,
@@ -531,6 +532,56 @@ describe('DA auth', () => {
531532
assert(!aclCtx.actionSet.has('write'));
532533
});
533534

535+
it('configPermissionPath returns CONFIG for org config', () => {
536+
assert.strictEqual(configPermissionPath({}), 'CONFIG');
537+
});
538+
539+
it('configPermissionPath returns /{site}/CONFIG for site config', () => {
540+
assert.strictEqual(configPermissionPath({ site: 'mysite' }), '/mysite/CONFIG');
541+
});
542+
543+
it('test site CONFIG governs site config read', async () => {
544+
const siteConfig = {
545+
test: {
546+
':type': 'sheet',
547+
':sheetname': 'permissions',
548+
data: [
549+
{ path: '/mysite/CONFIG', groups: 'reader@bloggs.org', actions: 'read' },
550+
{ path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' },
551+
],
552+
},
553+
};
554+
const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } };
555+
556+
const reader = [{ email: 'reader@bloggs.org' }];
557+
const aclCtx = await getAclCtx(siteEnv, 'test', reader, 'mysite/config.json', 'config');
558+
559+
// The index.js gate always allows reaching the config route for config requests.
560+
assert(aclCtx.actionSet.has('read'));
561+
562+
// The reader can read this site's config.
563+
assert(hasPermission({
564+
users: reader, org: 'test', aclCtx, key: 'mysite/config.json', site: 'mysite',
565+
}, configPermissionPath({ site: 'mysite' }), 'read', true));
566+
567+
// The reader cannot write this site's config.
568+
assert(!hasPermission({
569+
users: reader, org: 'test', aclCtx, key: 'mysite/config.json', site: 'mysite',
570+
}, configPermissionPath({ site: 'mysite' }), 'write', true));
571+
572+
// A user without the site CONFIG permission cannot read this site's config.
573+
const stranger = [{ email: 'orgadmin@bloggs.org' }];
574+
const strangerCtx = await getAclCtx(siteEnv, 'test', stranger, 'mysite/config.json', 'config');
575+
assert(!hasPermission({
576+
users: stranger, org: 'test', aclCtx: strangerCtx, key: 'mysite/config.json', site: 'mysite',
577+
}, configPermissionPath({ site: 'mysite' }), 'read', true));
578+
579+
// The site CONFIG permission does not grant read on a different site's config.
580+
assert(!hasPermission({
581+
users: reader, org: 'test', aclCtx, key: 'other/config.json', site: 'other',
582+
}, configPermissionPath({ site: 'other' }), 'read', true));
583+
});
584+
534585
it('test DA_OPS_IMS_ORG permissions', async () => {
535586
const opsOrg = 'MyOpsOrg';
536587
const envOps = {

0 commit comments

Comments
 (0)