From 8c859db187c8ded9d7a312e80813eb2306c6d3e8 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Wed, 17 Jun 2026 12:25:46 +0100 Subject: [PATCH 1/2] fix: derive config site from key and let site wildcards govern site config Two fixes for site config permissions: - configPermissionPath derived the site from daCtx.site, which is undefined for a bare /config/{org}/{site} request (the site is parsed as the filename). It now derives the site from daCtx.key, matching getAclCtx, so the route and the authorized gate agree. - A site-scoped content wildcard (/{site}/** or /{site}/+**), not just an exact /{site}/CONFIG rule, now activates the per-site /{site}/CONFIG keyword. Root wildcards (/**, /+**) remain non-activating so they do not suppress the org CONFIG fallback. Path matching is factored into pathMatchesTarget and shared. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/auth.js | 58 ++++++++++++++++------ test/utils/auth.test.js | 106 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 142 insertions(+), 22 deletions(-) diff --git a/src/utils/auth.js b/src/utils/auth.js index 1426ef6..412f02d 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -196,18 +196,26 @@ function getIdents(user) { return idents.map((ident) => ident?.toLowerCase()); } +/** + * Whether an ACL rule `path` covers a given `target` path, applying the wildcard + * conventions: `/x/+**` covers `/x` and everything below it, `/x/**` covers + * everything strictly below `/x`, and otherwise an exact match (with `.html` + * leniency) is required. + */ +export function pathMatchesTarget(path, target) { + if (path.endsWith('/+**')) return target.startsWith(path.slice(0, -3)) || target === path.slice(0, -4); + if (target.length < path.length) return false; + if (path.endsWith('/**')) return target.startsWith(path.slice(0, -2)); + if (target.endsWith('.html')) return target.slice(0, -5) === path || target === path; + return target === path; +} + export function getUserActions(pathLookup, user, target) { const idents = getIdents(user); const plVals = idents.map((key) => pathLookup.get(key) || []); const actions = plVals.map((entries) => entries - .find(({ path }) => { - if (path.endsWith('/+**')) return target.startsWith(path.slice(0, -3)) || target === path.slice(0, -4); - if (target.length < path.length) return false; - if (path.endsWith('/**')) return target.startsWith(path.slice(0, -2)); - if (target.endsWith('.html')) return target.slice(0, -5) === path || target === path; - return target === path; - })) + .find(({ path }) => pathMatchesTarget(path, target))) .filter((a) => a); return { @@ -233,10 +241,14 @@ export function pathSorter({ path: path1 }, { path: path2 }) { * * Org-level config (`/config/{org}`) is always governed by the `CONFIG` keyword. * Site-level config (`/config/{org}/{site}/...`) is governed by a per-site - * `/{site}/CONFIG` keyword, but only when such a rule is actually present in the - * permissions sheet. When no `/{site}/CONFIG` rule is specified, site config access - * falls back to the org-level `CONFIG` rule. The `CONFIG` portion is always uppercase - * so it cannot collide with a content path. + * `/{site}/CONFIG` keyword whenever the permissions sheet contains a site-scoped + * rule covering it — i.e. an explicit `/{site}/CONFIG` rule, or a site wildcard such + * as `/{site}/**` or `/{site}/+**`. When no such site-scoped rule exists, site config + * access falls back to the org-level `CONFIG` rule. Note that root wildcards (`/**`, + * `/+**`) do not activate the per-site keyword (they are not site-scoped, so they must + * not suppress the org `CONFIG` fallback), though once the keyword is active they do + * grant access to whoever holds them. The `CONFIG` portion is always uppercase so it + * cannot collide with a content path. * * @param {Map} pathLookup the parsed permissions, keyed by ident * @param {string} [site] the site, if this is a site config request @@ -245,18 +257,35 @@ export function pathSorter({ path: path1 }, { path: path2 }) { function resolveConfigKey(pathLookup, site) { if (!site) return 'CONFIG'; const siteKey = `/${site}/CONFIG`; + const sitePrefix = `/${site}/`; for (const entries of pathLookup?.values() ?? []) { - if (entries.some((entry) => entry.path === siteKey)) return siteKey; + if (entries.some((entry) => entry.path.startsWith(sitePrefix) + && pathMatchesTarget(entry.path, siteKey))) { + return siteKey; + } } return 'CONFIG'; } +/** + * The site of a config request, derived from its key (the path below the org). + * For `/config/{org}` the key is empty (org config, no site). For + * `/config/{org}/{site}` and `/config/{org}/{site}/...` the site is the first + * key segment. Note that `daCtx.site` is unreliable here: for the bare + * `/config/{org}/{site}` request the site name is parsed as the filename, leaving + * `daCtx.site` undefined, so the key is the source of truth. + */ +function configSite(key) { + const [site] = (key || '').split('/').filter((part) => part.length > 0); + return site; +} + /** * The keyword path that governs access to the config resource of the given request. * @see resolveConfigKey */ export function configPermissionPath(daCtx) { - return resolveConfigKey(daCtx.aclCtx?.pathLookup, daCtx.site); + return resolveConfigKey(daCtx.aclCtx?.pathLookup, configSite(daCtx.key)); } export async function getAclCtx(env, org, users, key, api) { @@ -359,8 +388,7 @@ export async function getAclCtx(env, org, users, key, api) { // Do a lookup for the base key, we always need this info let k; if (api === 'config') { - const [site] = key.split('/').filter((part) => part.length > 0); - k = resolveConfigKey(pathLookup, site); + k = resolveConfigKey(pathLookup, configSite(key)); } else { k = key.startsWith('/') ? key : `/${key}`; } diff --git a/test/utils/auth.test.js b/test/utils/auth.test.js index 161a2f0..9b3b77c 100644 --- a/test/utils/auth.test.js +++ b/test/utils/auth.test.js @@ -540,7 +540,17 @@ describe('DA auth', () => { const pathLookup = new Map([ ['someone@bloggs.org', [{ path: '/mysite/CONFIG', actions: ['read'] }]], ]); - const daCtx = { site: 'mysite', aclCtx: { pathLookup } }; + // The site is derived from the key, not daCtx.site: for a bare + // /config/{org}/{site} request the key is the site and daCtx.site is undefined. + const daCtx = { key: 'mysite', aclCtx: { pathLookup } }; + assert.strictEqual(configPermissionPath(daCtx), '/mysite/CONFIG'); + }); + + it('configPermissionPath derives the site from a deeper key', () => { + const pathLookup = new Map([ + ['someone@bloggs.org', [{ path: '/mysite/CONFIG', actions: ['read'] }]], + ]); + const daCtx = { key: 'mysite/public/config.json', aclCtx: { pathLookup } }; assert.strictEqual(configPermissionPath(daCtx), '/mysite/CONFIG'); }); @@ -548,7 +558,7 @@ describe('DA auth', () => { const pathLookup = new Map([ ['someone@bloggs.org', [{ path: 'CONFIG', actions: ['write'] }]], ]); - const daCtx = { site: 'mysite', aclCtx: { pathLookup } }; + const daCtx = { key: 'mysite', aclCtx: { pathLookup } }; assert.strictEqual(configPermissionPath(daCtx), 'CONFIG'); }); @@ -565,11 +575,13 @@ describe('DA auth', () => { }; const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; - // Build a site-config daCtx for the given users. + // Build a site-config daCtx for a bare `/config/{org}/{site}` request. The key + // is the site and daCtx.site is intentionally absent (it would be undefined for + // this URL because daCtx parsing consumes the site as the filename). const ctxFor = async (users, site) => { - const aclCtx = await getAclCtx(siteEnv, 'test', users, `${site}/config.json`, 'config'); + const aclCtx = await getAclCtx(siteEnv, 'test', users, site, 'config'); return { - users, org: 'test', aclCtx, key: `${site}/config.json`, site, + users, org: 'test', aclCtx, key: site, }; }; @@ -610,9 +622,9 @@ describe('DA auth', () => { const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; const ctxFor = async (users, site) => { - const aclCtx = await getAclCtx(siteEnv, 'test', users, `${site}/config.json`, 'config'); + const aclCtx = await getAclCtx(siteEnv, 'test', users, site, 'config'); return { - users, org: 'test', aclCtx, key: `${site}/config.json`, site, + users, org: 'test', aclCtx, key: site, }; }; @@ -632,6 +644,86 @@ describe('DA auth', () => { assert(!hasPermission(readerCtx, configPermissionPath(readerCtx), 'read', true)); }); + it('test site content wildcards grant access to the site config', async () => { + // No explicit /mysite/CONFIG rule: access is granted via site content wildcards. + const siteConfig = { + test: { + ':type': 'sheet', + ':sheetname': 'permissions', + data: [ + { path: '/mysite/+**', groups: 'plus@bloggs.org', actions: 'read' }, + { path: '/mysite/**', groups: 'star@bloggs.org', actions: 'write' }, + { path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' }, + ], + }, + }; + const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; + + const ctxFor = async (users) => { + const aclCtx = await getAclCtx(siteEnv, 'test', users, 'mysite', 'config'); + return { + users, org: 'test', aclCtx, key: 'mysite', + }; + }; + + // A /mysite/+** read rule grants read on /mysite/CONFIG (the bare /config/{org}/{site} URL). + const plus = [{ email: 'plus@bloggs.org' }]; + const plusCtx = await ctxFor(plus); + assert.strictEqual(configPermissionPath(plusCtx), '/mysite/CONFIG'); + assert(hasPermission(plusCtx, configPermissionPath(plusCtx), 'read', true)); + assert(!hasPermission(plusCtx, configPermissionPath(plusCtx), 'write', true)); + + // A /mysite/** write rule grants read+write on the site config. + const star = [{ email: 'star@bloggs.org' }]; + const starCtx = await ctxFor(star); + assert.strictEqual(configPermissionPath(starCtx), '/mysite/CONFIG'); + assert(hasPermission(starCtx, configPermissionPath(starCtx), 'read', true)); + assert(hasPermission(starCtx, configPermissionPath(starCtx), 'write', true)); + + // Since a site-scoped rule activates the /mysite/CONFIG keyword, an org-CONFIG-only + // admin without any /mysite/ rule is NOT granted access (no fallback to CONFIG). + const orgAdmin = [{ email: 'orgadmin@bloggs.org' }]; + const orgAdminCtx = await ctxFor(orgAdmin); + assert.strictEqual(configPermissionPath(orgAdminCtx), '/mysite/CONFIG'); + assert(!hasPermission(orgAdminCtx, configPermissionPath(orgAdminCtx), 'read', true)); + }); + + it('test root wildcards do not suppress the org CONFIG fallback', async () => { + // A root /+** rule is not site-scoped, so it must not activate the per-site + // keyword; site config still falls back to the org CONFIG rule. + const siteConfig = { + test: { + ':type': 'sheet', + ':sheetname': 'permissions', + data: [ + { path: '/+**', groups: 'root@bloggs.org', actions: 'write' }, + { path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' }, + ], + }, + }; + const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; + + const ctxFor = async (users) => { + const aclCtx = await getAclCtx(siteEnv, 'test', users, 'mysite', 'config'); + return { + users, org: 'test', aclCtx, key: 'mysite', + }; + }; + + // No site-scoped rule exists -> fall back to CONFIG. + const orgAdmin = [{ email: 'orgadmin@bloggs.org' }]; + const orgAdminCtx = await ctxFor(orgAdmin); + assert.strictEqual(configPermissionPath(orgAdminCtx), 'CONFIG'); + assert(hasPermission(orgAdminCtx, configPermissionPath(orgAdminCtx), 'read', true)); + + // The root-wildcard holder still gets in via the CONFIG fallback... only if they + // also hold CONFIG. /+** alone does not match the slash-less CONFIG keyword. + const root = [{ email: 'root@bloggs.org' }]; + const rootCtx = await ctxFor(root); + assert.strictEqual(configPermissionPath(rootCtx), 'CONFIG'); + assert(!hasPermission(rootCtx, configPermissionPath(rootCtx), 'read', true)); + }); + it('test DA_OPS_IMS_ORG permissions', async () => { const opsOrg = 'MyOpsOrg'; const envOps = { From 6efe08c30528401d7eb2ec27b5ffdb38635497e5 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Wed, 17 Jun 2026 13:08:42 +0100 Subject: [PATCH 2/2] refactor: grant site config access via site keyword or org CONFIG (union) Replace the site-config activation/fallback scan with a simpler union: config access is granted if the user matches the resource's own keyword (per-site /{site}/CONFIG, incl. site wildcards, or CONFIG for org config) OR the org-level CONFIG keyword. Org admins can always manage any site's config; site rules only add access, never restrict it. Removes resolveConfigKey and the whole-sheet scan; configPermissionPath becomes a trivial site->keyword mapping and the union OR lives in the config route. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/config.js | 13 ++- src/utils/auth.js | 65 +++---------- test/routes/config.test.js | 54 +++++++++++ test/utils/auth.test.js | 191 +++++-------------------------------- 4 files changed, 106 insertions(+), 217 deletions(-) diff --git a/src/routes/config.js b/src/routes/config.js index 9e557e4..f2b1ddd 100644 --- a/src/routes/config.js +++ b/src/routes/config.js @@ -14,8 +14,17 @@ import putKv from '../storage/kv/put.js'; import getKv from '../storage/kv/get.js'; import { configPermissionPath, hasPermission } from '../utils/auth.js'; +// Config access is granted if the user has the action on the resource's own keyword +// (the per-site `/{site}/CONFIG`, or `CONFIG` for org config) OR on the org-level +// `CONFIG` keyword. The latter lets org admins manage any site's config; site rules +// (including `/{site}/**` wildcards) can grant additional access but never restrict it. +function hasConfigPermission(daCtx, action) { + return hasPermission(daCtx, configPermissionPath(daCtx), action, true) + || hasPermission(daCtx, 'CONFIG', action, true); +} + export async function postConfig({ req, env, daCtx }) { - if (!hasPermission(daCtx, configPermissionPath(daCtx), 'write', true)) { + if (!hasConfigPermission(daCtx, 'write')) { return { status: 403 }; } @@ -23,7 +32,7 @@ export async function postConfig({ req, env, daCtx }) { } export async function getConfig({ env, daCtx }) { - if (!hasPermission(daCtx, configPermissionPath(daCtx), 'read', true)) { + if (!hasConfigPermission(daCtx, 'read')) { return { status: 403 }; } diff --git a/src/utils/auth.js b/src/utils/auth.js index 412f02d..71199fc 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -196,26 +196,18 @@ function getIdents(user) { return idents.map((ident) => ident?.toLowerCase()); } -/** - * Whether an ACL rule `path` covers a given `target` path, applying the wildcard - * conventions: `/x/+**` covers `/x` and everything below it, `/x/**` covers - * everything strictly below `/x`, and otherwise an exact match (with `.html` - * leniency) is required. - */ -export function pathMatchesTarget(path, target) { - if (path.endsWith('/+**')) return target.startsWith(path.slice(0, -3)) || target === path.slice(0, -4); - if (target.length < path.length) return false; - if (path.endsWith('/**')) return target.startsWith(path.slice(0, -2)); - if (target.endsWith('.html')) return target.slice(0, -5) === path || target === path; - return target === path; -} - export function getUserActions(pathLookup, user, target) { const idents = getIdents(user); const plVals = idents.map((key) => pathLookup.get(key) || []); const actions = plVals.map((entries) => entries - .find(({ path }) => pathMatchesTarget(path, target))) + .find(({ path }) => { + if (path.endsWith('/+**')) return target.startsWith(path.slice(0, -3)) || target === path.slice(0, -4); + if (target.length < path.length) return false; + if (path.endsWith('/**')) return target.startsWith(path.slice(0, -2)); + if (target.endsWith('.html')) return target.slice(0, -5) === path || target === path; + return target === path; + })) .filter((a) => a); return { @@ -236,37 +228,6 @@ export function pathSorter({ path: path1 }, { path: path2 }) { return sp2.length - sp1.length; } -/** - * Resolve the keyword path that governs access to a config resource. - * - * Org-level config (`/config/{org}`) is always governed by the `CONFIG` keyword. - * Site-level config (`/config/{org}/{site}/...`) is governed by a per-site - * `/{site}/CONFIG` keyword whenever the permissions sheet contains a site-scoped - * rule covering it — i.e. an explicit `/{site}/CONFIG` rule, or a site wildcard such - * as `/{site}/**` or `/{site}/+**`. When no such site-scoped rule exists, site config - * access falls back to the org-level `CONFIG` rule. Note that root wildcards (`/**`, - * `/+**`) do not activate the per-site keyword (they are not site-scoped, so they must - * not suppress the org `CONFIG` fallback), though once the keyword is active they do - * grant access to whoever holds them. The `CONFIG` portion is always uppercase so it - * cannot collide with a content path. - * - * @param {Map} pathLookup the parsed permissions, keyed by ident - * @param {string} [site] the site, if this is a site config request - * @returns {string} the keyword path governing this config resource - */ -function resolveConfigKey(pathLookup, site) { - if (!site) return 'CONFIG'; - const siteKey = `/${site}/CONFIG`; - const sitePrefix = `/${site}/`; - for (const entries of pathLookup?.values() ?? []) { - if (entries.some((entry) => entry.path.startsWith(sitePrefix) - && pathMatchesTarget(entry.path, siteKey))) { - return siteKey; - } - } - return 'CONFIG'; -} - /** * The site of a config request, derived from its key (the path below the org). * For `/config/{org}` the key is empty (org config, no site). For @@ -281,11 +242,14 @@ function configSite(key) { } /** - * The keyword path that governs access to the config resource of the given request. - * @see resolveConfigKey + * The keyword path naming the config resource of the given request: the per-site + * `/{site}/CONFIG` for site config, or the org-level `CONFIG` for org config. The + * `CONFIG` portion is always uppercase so it cannot collide with a content path. + * Access is granted via this keyword OR the org `CONFIG` keyword (see the config route). */ export function configPermissionPath(daCtx) { - return resolveConfigKey(daCtx.aclCtx?.pathLookup, configSite(daCtx.key)); + const site = configSite(daCtx.key); + return site ? `/${site}/CONFIG` : 'CONFIG'; } export async function getAclCtx(env, org, users, key, api) { @@ -388,7 +352,8 @@ export async function getAclCtx(env, org, users, key, api) { // Do a lookup for the base key, we always need this info let k; if (api === 'config') { - k = resolveConfigKey(pathLookup, configSite(key)); + const site = configSite(key); + k = site ? `/${site}/CONFIG` : 'CONFIG'; } else { k = key.startsWith('/') ? key : `/${key}`; } diff --git a/test/routes/config.test.js b/test/routes/config.test.js index 6aa8881..6362560 100644 --- a/test/routes/config.test.js +++ b/test/routes/config.test.js @@ -130,6 +130,60 @@ describe('Config', () => { assert.deepStrictEqual(getKVCalled, [{ e: env, c: ctx }]); }); + it('Test getConfig site config granted via the site keyword', async () => { + // Real configPermissionPath derives /mysite/CONFIG from the key. + const ctx = { key: 'mysite' }; + const env = {}; + + const getKVCalled = []; + const getKV = async (e, c) => { + getKVCalled.push({ e, c }); + return 'called'; + }; + + const hasPermission = (c, k, a, kw) => k === '/mysite/CONFIG' && a === 'read' && kw === true; + + const { getConfig } = await esmock('../../src/routes/config.js', { + '../../src/storage/kv/get.js': { + default: getKV, + }, + '../../src/utils/auth.js': { + hasPermission, + }, + }); + + const res = await getConfig({ env, daCtx: ctx }); + assert.strictEqual(res, 'called'); + assert.deepStrictEqual(getKVCalled, [{ e: env, c: ctx }]); + }); + + it('Test getConfig site config granted via the org CONFIG fallback', async () => { + // No /mysite/CONFIG permission, but the user holds the org CONFIG keyword. + const ctx = { key: 'mysite' }; + const env = {}; + + const getKVCalled = []; + const getKV = async (e, c) => { + getKVCalled.push({ e, c }); + return 'called'; + }; + + const hasPermission = (c, k, a, kw) => k === 'CONFIG' && a === 'read' && kw === true; + + const { getConfig } = await esmock('../../src/routes/config.js', { + '../../src/storage/kv/get.js': { + default: getKV, + }, + '../../src/utils/auth.js': { + hasPermission, + }, + }); + + const res = await getConfig({ env, daCtx: ctx }); + assert.strictEqual(res, 'called'); + assert.deepStrictEqual(getKVCalled, [{ e: env, c: ctx }]); + }); + it('Test no permission', async () => { const ctx = {}; const env = {}; diff --git a/test/utils/auth.test.js b/test/utils/auth.test.js index 9b3b77c..f435950 100644 --- a/test/utils/auth.test.js +++ b/test/utils/auth.test.js @@ -532,196 +532,57 @@ describe('DA auth', () => { assert(!aclCtx.actionSet.has('write')); }); - it('configPermissionPath returns CONFIG for org config', () => { + it('configPermissionPath maps a config request to its keyword', () => { + // Org config (no site) -> the CONFIG keyword. assert.strictEqual(configPermissionPath({}), 'CONFIG'); + assert.strictEqual(configPermissionPath({ key: '' }), 'CONFIG'); + // Site config -> /{site}/CONFIG, derived from the key. (daCtx.site is undefined + // for the bare /config/{org}/{site} URL, so the key is the source of truth.) + assert.strictEqual(configPermissionPath({ key: 'mysite' }), '/mysite/CONFIG'); + assert.strictEqual( + configPermissionPath({ key: 'mysite/public/config.json' }), + '/mysite/CONFIG', + ); }); - it('configPermissionPath returns /{site}/CONFIG when a site rule exists', () => { - const pathLookup = new Map([ - ['someone@bloggs.org', [{ path: '/mysite/CONFIG', actions: ['read'] }]], - ]); - // The site is derived from the key, not daCtx.site: for a bare - // /config/{org}/{site} request the key is the site and daCtx.site is undefined. - const daCtx = { key: 'mysite', aclCtx: { pathLookup } }; - assert.strictEqual(configPermissionPath(daCtx), '/mysite/CONFIG'); - }); - - it('configPermissionPath derives the site from a deeper key', () => { - const pathLookup = new Map([ - ['someone@bloggs.org', [{ path: '/mysite/CONFIG', actions: ['read'] }]], - ]); - const daCtx = { key: 'mysite/public/config.json', aclCtx: { pathLookup } }; - assert.strictEqual(configPermissionPath(daCtx), '/mysite/CONFIG'); - }); - - it('configPermissionPath falls back to CONFIG when no site rule exists', () => { - const pathLookup = new Map([ - ['someone@bloggs.org', [{ path: 'CONFIG', actions: ['write'] }]], - ]); - const daCtx = { key: 'mysite', aclCtx: { pathLookup } }; - assert.strictEqual(configPermissionPath(daCtx), 'CONFIG'); - }); - - it('test site CONFIG governs site config read when a site rule is specified', async () => { - const siteConfig = { - test: { - ':type': 'sheet', - ':sheetname': 'permissions', - data: [ - { path: '/mysite/CONFIG', groups: 'reader@bloggs.org', actions: 'read' }, - { path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' }, - ], - }, - }; - const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; - - // Build a site-config daCtx for a bare `/config/{org}/{site}` request. The key - // is the site and daCtx.site is intentionally absent (it would be undefined for - // this URL because daCtx parsing consumes the site as the filename). - const ctxFor = async (users, site) => { - const aclCtx = await getAclCtx(siteEnv, 'test', users, site, 'config'); - return { - users, org: 'test', aclCtx, key: site, - }; - }; - - const reader = [{ email: 'reader@bloggs.org' }]; - const readerCtx = await ctxFor(reader, 'mysite'); - - // The index.js gate always allows reaching the config route for config requests. - assert(readerCtx.aclCtx.actionSet.has('read')); - - // A /mysite/CONFIG rule exists, so the site keyword governs this site's config. - assert.strictEqual(configPermissionPath(readerCtx), '/mysite/CONFIG'); - - // The reader can read this site's config. - assert(hasPermission(readerCtx, configPermissionPath(readerCtx), 'read', true)); - // ...but cannot write it. - assert(!hasPermission(readerCtx, configPermissionPath(readerCtx), 'write', true)); - - // An org-CONFIG holder without /mysite/CONFIG cannot read this site's config, - // because a /mysite/CONFIG rule is specified (no fallback to the CONFIG rule). - const orgAdmin = [{ email: 'orgadmin@bloggs.org' }]; - const orgAdminCtx = await ctxFor(orgAdmin, 'mysite'); - assert.strictEqual(configPermissionPath(orgAdminCtx), '/mysite/CONFIG'); - assert(!hasPermission(orgAdminCtx, configPermissionPath(orgAdminCtx), 'read', true)); - }); - - it('test site config falls back to CONFIG rule when no site rule is specified', async () => { + it('site config permission is granted by the site keyword or a site wildcard', async () => { + // Building blocks for the union model: the route grants config access if the user + // matches the /{site}/CONFIG keyword OR the org CONFIG keyword (see config route). const siteConfig = { test: { ':type': 'sheet', ':sheetname': 'permissions', data: [ - // Note: no /othersite/CONFIG rule is specified anywhere. - { path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' }, { path: '/mysite/CONFIG', groups: 'reader@bloggs.org', actions: 'read' }, - ], - }, - }; - const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; - - const ctxFor = async (users, site) => { - const aclCtx = await getAclCtx(siteEnv, 'test', users, site, 'config'); - return { - users, org: 'test', aclCtx, key: site, - }; - }; - - // No /othersite/CONFIG rule -> falls back to the org-level CONFIG rule. - const orgAdmin = [{ email: 'orgadmin@bloggs.org' }]; - const orgAdminCtx = await ctxFor(orgAdmin, 'othersite'); - assert.strictEqual(configPermissionPath(orgAdminCtx), 'CONFIG'); - // The CONFIG rule grants orgAdmin write (and therefore read) on this site's config. - assert(hasPermission(orgAdminCtx, configPermissionPath(orgAdminCtx), 'read', true)); - assert(hasPermission(orgAdminCtx, configPermissionPath(orgAdminCtx), 'write', true)); - - // A user with only /mysite/CONFIG does not inherit access to othersite via the - // fallback, since the fallback follows the CONFIG rule which they do not hold. - const reader = [{ email: 'reader@bloggs.org' }]; - const readerCtx = await ctxFor(reader, 'othersite'); - assert.strictEqual(configPermissionPath(readerCtx), 'CONFIG'); - assert(!hasPermission(readerCtx, configPermissionPath(readerCtx), 'read', true)); - }); - - it('test site content wildcards grant access to the site config', async () => { - // No explicit /mysite/CONFIG rule: access is granted via site content wildcards. - const siteConfig = { - test: { - ':type': 'sheet', - ':sheetname': 'permissions', - data: [ { path: '/mysite/+**', groups: 'plus@bloggs.org', actions: 'read' }, { path: '/mysite/**', groups: 'star@bloggs.org', actions: 'write' }, - { path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' }, ], }, }; const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; + // A bare /config/{org}/{site} request: key is the site, daCtx.site absent. const ctxFor = async (users) => { const aclCtx = await getAclCtx(siteEnv, 'test', users, 'mysite', 'config'); return { users, org: 'test', aclCtx, key: 'mysite', }; }; + const siteKey = '/mysite/CONFIG'; - // A /mysite/+** read rule grants read on /mysite/CONFIG (the bare /config/{org}/{site} URL). - const plus = [{ email: 'plus@bloggs.org' }]; - const plusCtx = await ctxFor(plus); - assert.strictEqual(configPermissionPath(plusCtx), '/mysite/CONFIG'); - assert(hasPermission(plusCtx, configPermissionPath(plusCtx), 'read', true)); - assert(!hasPermission(plusCtx, configPermissionPath(plusCtx), 'write', true)); - - // A /mysite/** write rule grants read+write on the site config. - const star = [{ email: 'star@bloggs.org' }]; - const starCtx = await ctxFor(star); - assert.strictEqual(configPermissionPath(starCtx), '/mysite/CONFIG'); - assert(hasPermission(starCtx, configPermissionPath(starCtx), 'read', true)); - assert(hasPermission(starCtx, configPermissionPath(starCtx), 'write', true)); - - // Since a site-scoped rule activates the /mysite/CONFIG keyword, an org-CONFIG-only - // admin without any /mysite/ rule is NOT granted access (no fallback to CONFIG). - const orgAdmin = [{ email: 'orgadmin@bloggs.org' }]; - const orgAdminCtx = await ctxFor(orgAdmin); - assert.strictEqual(configPermissionPath(orgAdminCtx), '/mysite/CONFIG'); - assert(!hasPermission(orgAdminCtx, configPermissionPath(orgAdminCtx), 'read', true)); - }); - - it('test root wildcards do not suppress the org CONFIG fallback', async () => { - // A root /+** rule is not site-scoped, so it must not activate the per-site - // keyword; site config still falls back to the org CONFIG rule. - const siteConfig = { - test: { - ':type': 'sheet', - ':sheetname': 'permissions', - data: [ - { path: '/+**', groups: 'root@bloggs.org', actions: 'write' }, - { path: 'CONFIG', groups: 'orgadmin@bloggs.org', actions: 'write' }, - ], - }, - }; - const siteEnv = { DA_CONFIG: { get: (name) => siteConfig[name] } }; + // An explicit /mysite/CONFIG read rule grants read but not write. + const reader = await ctxFor([{ email: 'reader@bloggs.org' }]); + assert(hasPermission(reader, siteKey, 'read', true)); + assert(!hasPermission(reader, siteKey, 'write', true)); - const ctxFor = async (users) => { - const aclCtx = await getAclCtx(siteEnv, 'test', users, 'mysite', 'config'); - return { - users, org: 'test', aclCtx, key: 'mysite', - }; - }; + // A /mysite/+** read wildcard matches the site keyword. + const plus = await ctxFor([{ email: 'plus@bloggs.org' }]); + assert(hasPermission(plus, siteKey, 'read', true)); - // No site-scoped rule exists -> fall back to CONFIG. - const orgAdmin = [{ email: 'orgadmin@bloggs.org' }]; - const orgAdminCtx = await ctxFor(orgAdmin); - assert.strictEqual(configPermissionPath(orgAdminCtx), 'CONFIG'); - assert(hasPermission(orgAdminCtx, configPermissionPath(orgAdminCtx), 'read', true)); - - // The root-wildcard holder still gets in via the CONFIG fallback... only if they - // also hold CONFIG. /+** alone does not match the slash-less CONFIG keyword. - const root = [{ email: 'root@bloggs.org' }]; - const rootCtx = await ctxFor(root); - assert.strictEqual(configPermissionPath(rootCtx), 'CONFIG'); - assert(!hasPermission(rootCtx, configPermissionPath(rootCtx), 'read', true)); + // A /mysite/** write wildcard grants read+write on the site keyword. + const star = await ctxFor([{ email: 'star@bloggs.org' }]); + assert(hasPermission(star, siteKey, 'read', true)); + assert(hasPermission(star, siteKey, 'write', true)); }); it('test DA_OPS_IMS_ORG permissions', async () => {