From 9e50da9d1017f3b3e39fbb1ec68b063a7d67928d Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 14 Aug 2026 17:11:08 -0400 Subject: [PATCH] feat(permissions): let manage answer a write check A manage grant let its holder re-share a folder but not work in it: the ACL mode family stops at write, and the fs exploder had no rule for the narrowest mode, so `manage:fs:` never satisfied `fs::write`. Fold manage into the candidate list for every non-manage mode, in both the access-token branch and the scan branch, and give `write` an (empty) exploder rule so the manage arm is emitted for it too. --- src/backend/services/acl/ACLService.test.ts | 48 +++++++++++++++++++++ src/backend/services/acl/ACLService.ts | 44 ++++++++----------- src/backend/services/fs/FSService.test.ts | 7 +++ src/backend/services/fs/FSService.ts | 2 + 4 files changed, 75 insertions(+), 26 deletions(-) diff --git a/src/backend/services/acl/ACLService.test.ts b/src/backend/services/acl/ACLService.test.ts index d83a2ab29..0b9007c42 100644 --- a/src/backend/services/acl/ACLService.test.ts +++ b/src/backend/services/acl/ACLService.test.ts @@ -522,6 +522,39 @@ describe('ACLService.check — stronger modes imply weaker ones', () => { ]); }); + it.each(['see', 'list', 'read', 'write'] as const)( + 'answers %s from a manage grant on the same node', + async (mode) => { + const { service, services } = makeService(); + services.permission.scan.mockImplementation( + async (_actor: unknown, permissions: string[]) => + permissions.includes('manage:fs:uid\\C/other/f') + ? [{ $: 'option', key: 'k' }] + : [], + ); + expect( + await service.check(issuerActor, resource('/other/f'), mode), + ).toBe(true); + }, + ); + + it('answers write from a manage grant on an ancestor directory', async () => { + const { service, services } = makeService(); + services.permission.scan.mockImplementation( + async (_actor: unknown, permissions: string[]) => + permissions.includes('manage:fs:uid\\C/other') + ? [{ $: 'option', key: 'k' }] + : [], + ); + expect( + await service.check( + issuerActor, + resource('/other/deep/file.txt'), + 'write', + ), + ).toBe(true); + }); + it('inherits access granted on an ancestor directory', async () => { const { service, services } = makeService(); services.permission.scan.mockImplementation( @@ -569,6 +602,21 @@ describe('ACLService.check — scoped tokens and manage', () => { ).toBe(true); }); + it('accepts a manage grant recorded against the token for a write', async () => { + const { service, stores } = makeService(); + stores.permission.hasAccessTokenPerm.mockImplementation( + async (_uid: string, permission: string) => + permission === 'manage:fs:uid\\C/issuer/projects', + ); + expect( + await service.check( + scopedTokenActor(), + resource('/issuer/projects'), + 'write', + ), + ).toBe(true); + }); + it('accepts an ancestor grant recorded against the token', async () => { const { service, stores } = makeService(); stores.permission.hasAccessTokenPerm.mockImplementation( diff --git a/src/backend/services/acl/ACLService.ts b/src/backend/services/acl/ACLService.ts index 1fbdf35e6..d78401d49 100644 --- a/src/backend/services/acl/ACLService.ts +++ b/src/backend/services/acl/ACLService.ts @@ -180,19 +180,10 @@ export class ACLService extends PuterService { if (actor.accessToken.fullAccess) return true; for (const ancestor of ancestors) { - const permissions = - mode === MANAGE_PERM_PREFIX - ? [ - PermissionUtil.join( - MANAGE_PERM_PREFIX, - 'fs', - ancestor.uid, - ), - ] - : MODES_ABOVE[mode].map((m) => - PermissionUtil.join('fs', ancestor.uid, m), - ); - for (const permission of permissions) { + for (const permission of this.#permissionsFor( + ancestor.uid, + mode, + )) { if ( await this.stores.permission.hasAccessTokenPerm( actor.accessToken.uid, @@ -227,21 +218,9 @@ export class ACLService extends PuterService { // Widen the scan to all "higher" modes (`write` covers `read`/`list`/ // `see`, etc.) so granting a stronger mode implies the weaker ones. for (const ancestor of ancestors) { - const permissions = - mode === MANAGE_PERM_PREFIX - ? [ - PermissionUtil.join( - MANAGE_PERM_PREFIX, - 'fs', - ancestor.uid, - ), - ] - : MODES_ABOVE[mode].map((m) => - PermissionUtil.join('fs', ancestor.uid, m), - ); const reading = await this.services.permission.scan( actor, - permissions, + this.#permissionsFor(ancestor.uid, mode), ); const options = PermissionUtil.readingToOptions(reading); if (options.length > 0) return true; @@ -250,6 +229,19 @@ export class ACLService extends PuterService { return false; } + /** + * Permissions on `uid` that satisfy `mode`. `manage` sits above the whole + * family — it answers any mode, but nothing answers it. + */ + #permissionsFor(uid: string, mode: AclMode): string[] { + const manage = PermissionUtil.join(MANAGE_PERM_PREFIX, 'fs', uid); + if (mode === MANAGE_PERM_PREFIX) return [manage]; + return [ + ...MODES_ABOVE[mode].map((m) => PermissionUtil.join('fs', uid, m)), + manage, + ]; + } + /** * When a check fails, return a user-safe error: 404 if the actor can't even * `see` the resource (don't leak existence), 403 otherwise. diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index 61889d65a..3bdee8bf4 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -2948,6 +2948,13 @@ describe('FSService permission rules', () => { ); expect(higher).not.toContain(`fs:${file.uuid}:read`); }); + + it('lets a manage grant answer a write on the narrowest mode', async () => { + const higher = await server.services.permission.getHigherPermissions( + `fs:${file.uuid}:write`, + ); + expect(higher).toContain(`manage:fs:${file.uuid}`); + }); }); // -- Cross-app AppData (app-data::fs:) ---------------------- diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index b23ba30a5..866804f99 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -359,6 +359,8 @@ export class FSService extends PuterService { see: ['list', 'read', 'write'], list: ['read', 'write'], read: ['write'], + // Widens to nothing, but still emits the manage arm. + write: [], }; permissions.registerExploder({ id: 'fs-access-levels',