fix(perms): let a write grant satisfy a read check on apps and subdomains

`apps-of-user:<uuid>:write` covers managing the user's apps, which includes
reading them, but nothing said so to the permission system. Prefix implication
only widens the other way — an `apps-of-user:<uuid>` grant covers both modes —
so a scan for `:read` missed a `:write` grant, and `puter.perms.check('apps')`
reported an app holding write as holding nothing. A batched request would then
prompt again for access already granted.

Adds the read-from-write exploder for both namespaces, mirroring
`fs-access-levels`. The widening runs one way only, and does not cross into
another user's namespace; both are covered by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Salazar
2026-08-21 01:30:04 -04:00
co-authored by Claude Opus 5
parent 7ec8e19bf9
commit d8d4aeee3d
2 changed files with 109 additions and 0 deletions
@@ -215,6 +215,92 @@ describe('AppPermissionService — own-apps / own-subdomains implicator', () =>
),
).toBe(false);
});
// `write` covers managing them, which includes reading them. Prefix
// implication only widens the other way, so this needs the exploder.
it.each([
['apps-of-user'],
['subdomains-of-user'],
])('%s: a granted write satisfies a read check', async (namespace) => {
const user = await makeUser();
const app = await makeApp(user.user.id!);
const appActor: Actor = {
user: user.user,
app: { uid: app.uid, id: app.id },
};
const uuid = user.user.uuid;
expect(
await permissions.check(appActor, `${namespace}:${uuid}:read`),
).toBe(false);
await runWithContext({ actor: user }, () =>
permissions.grantUserAppPermission(
user,
app.uid,
`${namespace}:${uuid}:write`,
),
);
expect(
await permissions.check(appActor, `${namespace}:${uuid}:read`),
).toBe(true);
expect(
await permissions.check(appActor, `${namespace}:${uuid}:write`),
).toBe(true);
});
// The widening runs one way only: a read grant is not a licence to manage.
it('does not let a granted read satisfy a write check', async () => {
const user = await makeUser();
const app = await makeApp(user.user.id!);
const appActor: Actor = {
user: user.user,
app: { uid: app.uid, id: app.id },
};
const uuid = user.user.uuid;
await runWithContext({ actor: user }, () =>
permissions.grantUserAppPermission(
user,
app.uid,
`apps-of-user:${uuid}:read`,
),
);
expect(
await permissions.check(appActor, `apps-of-user:${uuid}:read`),
).toBe(true);
expect(
await permissions.check(appActor, `apps-of-user:${uuid}:write`),
).toBe(false);
});
// Another user's namespace stays out of reach however wide the grant.
it("does not widen into another user's namespace", async () => {
const user = await makeUser();
const other = await makeUser();
const app = await makeApp(user.user.id!);
const appActor: Actor = {
user: user.user,
app: { uid: app.uid, id: app.id },
};
await runWithContext({ actor: user }, () =>
permissions.grantUserAppPermission(
user,
app.uid,
`apps-of-user:${user.user.uuid}:write`,
),
);
expect(
await permissions.check(
appActor,
`apps-of-user:${other.user.uuid}:read`,
),
).toBe(false);
});
});
// -- app-root-dir:<app_uid>:<mode> → fs:<uuid>:<mode> ------------------
@@ -126,6 +126,29 @@ export class AppPermissionService extends PuterService {
},
});
// -- apps-of-user:<uuid>:read ← :write --------------------------
// `write` covers managing them, which includes reading them. Prefix
// implication only widens the other way — an `apps-of-user:<uuid>`
// grant covers both modes — so without this a scan for `read` misses
// a `write` grant, and `puter.perms.check('apps')` would report an
// app that holds write as holding nothing. Mirrors `fs-access-levels`.
permissions.registerExploder({
id: 'apps-of-user-access-levels',
matches: (permission: string) =>
(permission.startsWith('apps-of-user:') ||
permission.startsWith('subdomains-of-user:')) &&
PermissionUtil.split(permission).length >= 3,
explode: ({ permission }) => {
const parts = PermissionUtil.split(permission);
if (parts[2] !== 'read') return [permission];
const [namespace, userUuid, , ...rest] = parts;
return [
permission,
PermissionUtil.join(namespace, userUuid, 'write', ...rest),
];
},
});
// -- app-root-dir:<app_uid>:<mode> → fs:<root_uid>:<mode> -------
// Only rewrites while a user-app permission row is being written or
// removed — `grantUserAppPermission` / `revokeUserAppPermission`, which