diff --git a/src/backend/services/apps/AppPermissionService.test.ts b/src/backend/services/apps/AppPermissionService.test.ts index fa04afe38..f9a0fd0aa 100644 --- a/src/backend/services/apps/AppPermissionService.test.ts +++ b/src/backend/services/apps/AppPermissionService.test.ts @@ -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:: → fs:: ------------------ diff --git a/src/backend/services/apps/AppPermissionService.ts b/src/backend/services/apps/AppPermissionService.ts index ae30decb6..2f7bb6ea1 100644 --- a/src/backend/services/apps/AppPermissionService.ts +++ b/src/backend/services/apps/AppPermissionService.ts @@ -126,6 +126,29 @@ export class AppPermissionService extends PuterService { }, }); + // -- apps-of-user::read ← :write -------------------------- + // `write` covers managing them, which includes reading them. Prefix + // implication only widens the other way — an `apps-of-user:` + // 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:: → fs:: ------- // Only rewrites while a user-app permission row is being written or // removed — `grantUserAppPermission` / `revokeUserAppPermission`, which