Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -390,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();
}
Expand Down
38 changes: 36 additions & 2 deletions test/utils/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
};
Expand All @@ -579,11 +580,44 @@ 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('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 () => {
Expand Down
Loading