From a205953d44daac42b7cfa956e311425103daf06e Mon Sep 17 00:00:00 2001 From: kptdobe Date: Tue, 21 Jul 2026 15:52:26 +0200 Subject: [PATCH 1/2] fix: site write access must not implicitly grant CONFIG write /site/CONFIG was reachable via any /site/** or /site/+** write rule, so anyone with write access to content could also write the config. Config write now requires an explicit rule targeting the CONFIG keyword itself (site or org level); wildcard/recursive site rules still imply CONFIG read. Co-Authored-By: Claude Sonnet 5 --- src/utils/auth.js | 13 ++++++++++++- test/utils/auth.test.js | 13 +++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/utils/auth.js b/src/utils/auth.js index bbc17a11..13f8046c 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -207,8 +207,16 @@ function dotFolderVariant(prefix) { return `${trimmed.slice(0, slashIdx + 1)}.${lastSegment}/`; } +// A CONFIG keyword (`CONFIG` or `/{site}/CONFIG`) grants read to anyone with access to the +// site/org (matched via a wildcard rule below), but write only if a rule targets the keyword +// itself explicitly. This keeps site content-write grants from implicitly unlocking config writes. +function isConfigKeyword(target) { + return target === 'CONFIG' || target.endsWith('/CONFIG'); +} + export function getUserActions(pathLookup, user, target) { const idents = getIdents(user); + const isConfigTarget = isConfigKeyword(target); const plVals = idents.map((key) => pathLookup.get(key) || []); const actions = plVals.map((entries) => entries @@ -231,7 +239,10 @@ export function getUserActions(pathLookup, user, target) { .filter((a) => a); return { - actions: new Set(actions.flatMap(({ actions: acts }) => acts)), + actions: new Set(actions.flatMap(({ actions: acts, path }) => { + if (isConfigTarget && path !== target) return acts.filter((act) => act === 'read'); + return acts; + })), trace: actions, }; } diff --git a/test/utils/auth.test.js b/test/utils/auth.test.js index 0e8ad329..58964548 100644 --- a/test/utils/auth.test.js +++ b/test/utils/auth.test.js @@ -557,6 +557,7 @@ describe('DA auth', () => { { path: '/mysite/CONFIG', groups: 'reader@bloggs.org', actions: 'read' }, { path: '/mysite/+**', groups: 'plus@bloggs.org', actions: 'read' }, { path: '/mysite/**', groups: 'star@bloggs.org', actions: 'write' }, + { path: '/mysite/CONFIG', groups: 'admin@bloggs.org', actions: 'write' }, ], }, }; @@ -579,11 +580,19 @@ describe('DA auth', () => { // A /mysite/+** read wildcard matches the site keyword. const plus = await ctxFor([{ email: 'plus@bloggs.org' }]); assert(hasPermission(plus, siteKey, 'read', true)); + assert(!hasPermission(plus, siteKey, 'write', true)); - // A /mysite/** write wildcard grants read+write on the site keyword. + // A /mysite/** write wildcard grants read but NOT write on the site keyword: site + // write access must not implicitly unlock config write, only an explicit CONFIG + // rule can grant that. const star = await ctxFor([{ email: 'star@bloggs.org' }]); assert(hasPermission(star, siteKey, 'read', true)); - assert(hasPermission(star, siteKey, 'write', true)); + assert(!hasPermission(star, siteKey, 'write', true)); + + // An explicit `write` rule on /mysite/CONFIG itself still grants write. + const admin = await ctxFor([{ email: 'admin@bloggs.org' }]); + assert(hasPermission(admin, siteKey, 'read', true)); + assert(hasPermission(admin, siteKey, 'write', true)); }); it('test DA_OPS_IMS_ORG permissions', async () => { From 046951639fe902a71b4062a38ecbf9c9c390db9a Mon Sep 17 00:00:00 2001 From: kptdobe Date: Wed, 22 Jul 2026 09:55:36 +0200 Subject: [PATCH 2/2] fix: cached config actionSet must reflect the org CONFIG fallback getAclCtx() built its cached actionSet for a config request by looking up only the site-specific /{site}/CONFIG keyword, never the org-level CONFIG keyword. hasConfigPermission() in the config route ORs both, so a user holding only the org-level CONFIG rule could actually POST/GET config, but the actionSet exposed via the X-da-actions/X-da-child-actions headers lacked write - permanently locking the config editor UI for that user once the document existed. Co-Authored-By: Claude Sonnet 5 --- src/utils/auth.js | 12 ++++++++++++ test/utils/auth.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/utils/auth.js b/src/utils/auth.js index 13f8046c..db70cd00 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -401,6 +401,18 @@ export async function getAclCtx(env, org, users, key, api) { actionSet = actionSet.intersection(ua.actions); ua.trace.forEach((t) => actionTrace.push(t)); }); + + // Site CONFIG access can also come from the org-level CONFIG keyword (see + // hasConfigPermission in the config route). Mirror that OR here so the cached + // actionSet - exposed to clients via the X-da-actions/X-da-child-actions headers - + // matches what the config route actually allows. + if (api === 'config' && k !== 'CONFIG') { + let orgActionSet = getUserActions(pathLookup, firstUser, 'CONFIG').actions; + otherUsers.forEach((u) => { + orgActionSet = orgActionSet.intersection(getUserActions(pathLookup, u, 'CONFIG').actions); + }); + actionSet = actionSet.union(orgActionSet); + } } else { actionSet = new Set(); } diff --git a/test/utils/auth.test.js b/test/utils/auth.test.js index 58964548..72cac722 100644 --- a/test/utils/auth.test.js +++ b/test/utils/auth.test.js @@ -595,6 +595,31 @@ describe('DA auth', () => { assert(hasPermission(admin, siteKey, 'write', true)); }); + it('getAclCtx actionSet for a config request includes org CONFIG fallback actions', async () => { + // Regression test: the cached actionSet built by getAclCtx() for api === 'config' + // used to only look up the site-specific /{site}/CONFIG keyword, never the + // org-level CONFIG keyword. Since this actionSet is what gets exposed to clients + // via the X-da-actions/X-da-child-actions headers (see daResp.js), a user who only + // holds the org-level CONFIG rule would pass the real write check in postConfig + // (hasConfigPermission ORs both keywords) but the header would still say + // read-only, permanently locking the config editor UI once the doc exists. + const orgOnlyConfig = { + test: { + ':type': 'sheet', + ':sheetname': 'permissions', + data: [ + { path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' }, + ], + }, + }; + const orgOnlyEnv = { DA_CONFIG: { get: (name) => orgOnlyConfig[name] } }; + const users = [{ email: 'orgadmin@bloggs.org' }]; + + const aclCtx = await getAclCtx(orgOnlyEnv, 'test', users, 'mysite', 'config'); + assert(aclCtx.actionSet.has('read')); + assert(aclCtx.actionSet.has('write')); + }); + it('test DA_OPS_IMS_ORG permissions', async () => { const opsOrg = 'MyOpsOrg'; const envOps = {