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:<uid>` never satisfied `fs:<uid>: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.
This commit is contained in:
Juan Castro
2026-08-14 17:11:08 -04:00
parent ed2b95695c
commit 9e50da9d10
4 changed files with 75 additions and 26 deletions
@@ -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(
+18 -26
View File
@@ -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.
@@ -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:<uid>:fs:<class>) ----------------------
+2
View File
@@ -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',