Skip to content

Commit 453476f

Browse files
committed
fix(security): surface swallowed permission-set resolution failures (#2565)
resolvePermissionSets swallowed metadata list() and sys_permission_set dbLoader failures silently — fail-closed (unresolvable sets grant nothing), but a transient DB error made custom permission sets vanish with no trace, leaving the resulting 403s undiagnosable. The evaluator now accepts an optional { logger } and emits one warn per failed source, naming the unresolved sets + the error; SecurityPlugin wires its logger into both call sites. Resolution behavior is byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT
1 parent 7c09621 commit 453476f

4 files changed

Lines changed: 82 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): surface swallowed permission-set resolution failures (#2565)
6+
7+
`PermissionEvaluator.resolvePermissionSets` swallowed metadata `list()` and
8+
`sys_permission_set` dbLoader failures silently — fail-closed (unresolvable
9+
sets grant nothing), but a transient DB error made custom permission sets
10+
vanish with no trace, leaving the resulting 403s undiagnosable. The evaluator
11+
now accepts an optional `{ logger }` and emits one `warn` per failed source,
12+
naming the unresolved permission sets and the error. SecurityPlugin wires its
13+
plugin logger into both call sites. Resolution behavior is byte-identical.

packages/plugins/plugin-security/src/permission-evaluator.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,15 @@ export class PermissionEvaluator {
266266
/**
267267
* Optional async loader for permission set names that aren't found in
268268
* metadata or bootstrap. Lets callers query user-defined permission
269-
* sets persisted in `sys_permission_set`. Failures are swallowed.
269+
* sets persisted in `sys_permission_set`. Failures are swallowed
270+
* (fail-closed: unresolvable sets grant nothing) but SURFACED via
271+
* `options.logger` — see #2565: without the warn, a transient DB error
272+
* makes custom permission sets silently vanish and the resulting 403s
273+
* are undiagnosable.
270274
*/
271-
dbLoader?: (unresolved: string[]) => Promise<PermissionSet[]>
275+
dbLoader?: (unresolved: string[]) => Promise<PermissionSet[]>,
276+
/** Optional logger; only `warn` is used. Resolution behavior is unchanged. */
277+
options: { logger?: { warn?: (msg: string, meta?: Record<string, any>) => void } } = {},
272278
): Promise<PermissionSet[]> {
273279
if (identifiers.length === 0) return [];
274280

@@ -283,8 +289,12 @@ export class PermissionEvaluator {
283289
?? metadataService?.list?.('permissions')
284290
?? [];
285291
allPermSets = typeof (listed as any)?.then === 'function' ? await listed : listed;
286-
} catch {
292+
} catch (e) {
287293
allPermSets = [];
294+
options.logger?.warn?.(
295+
'[security] permission-set metadata list() failed — falling back to bootstrap/db sources (#2565)',
296+
{ requested: identifiers, error: (e as Error)?.message },
297+
);
288298
}
289299
if (!Array.isArray(allPermSets)) allPermSets = [];
290300

@@ -322,9 +332,16 @@ export class PermissionEvaluator {
322332
result.push(ps);
323333
}
324334
}
325-
} catch {
335+
} catch (e) {
326336
// Swallow — the request shouldn't fail just because the DB
327-
// lookup is unavailable.
337+
// lookup is unavailable (fail-closed: the unresolved sets simply
338+
// grant nothing). But surface it: without this warn a transient
339+
// DB error silently drops custom permission sets and the
340+
// resulting 403s point nowhere near the cause (#2565).
341+
options.logger?.warn?.(
342+
'[security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)',
343+
{ unresolved, error: (e as Error)?.message },
344+
);
328345
}
329346
}
330347
}

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,51 @@ describe('PermissionEvaluator', () => {
10691069
expect(result).toEqual([psAdmin]);
10701070
});
10711071

1072+
it('warns (and keeps resolving) when the dbLoader throws — #2565 observability', async () => {
1073+
const evaluator = new PermissionEvaluator();
1074+
const psAdmin = { name: 'admin_full_access' };
1075+
const metadata = { list: vi.fn().mockReturnValue([psAdmin]) };
1076+
const warns: Array<{ msg: string; meta?: Record<string, any> }> = [];
1077+
const result = await evaluator.resolvePermissionSets(
1078+
['admin_full_access', 'custom_sales'],
1079+
metadata,
1080+
[],
1081+
async () => { throw new Error('db down'); },
1082+
{ logger: { warn: (msg, meta) => warns.push({ msg, meta }) } },
1083+
);
1084+
// behavior unchanged: metadata-resolved set still returned, unresolved grants nothing
1085+
expect(result).toEqual([psAdmin]);
1086+
// ...but the swallow is surfaced, naming the unresolved sets
1087+
expect(warns.length).toBe(1);
1088+
expect(warns[0].msg).toContain('db lookup failed');
1089+
expect(warns[0].meta?.unresolved).toEqual(['custom_sales']);
1090+
expect(warns[0].meta?.error).toBe('db down');
1091+
});
1092+
1093+
it('warns (and keeps resolving via bootstrap) when metadata list() throws — #2565', async () => {
1094+
const evaluator = new PermissionEvaluator();
1095+
const metadata = { list: vi.fn().mockImplementation(() => { throw new Error('index broken'); }) };
1096+
const bootstrap = [{ name: 'member_default', objects: {} } as any];
1097+
const warns: string[] = [];
1098+
const result = await evaluator.resolvePermissionSets(
1099+
['member_default'],
1100+
metadata,
1101+
bootstrap,
1102+
undefined,
1103+
{ logger: { warn: (msg) => warns.push(msg) } },
1104+
);
1105+
expect(result.map((p) => p.name)).toEqual(['member_default']);
1106+
expect(warns.some((w) => w.includes('metadata list() failed'))).toBe(true);
1107+
});
1108+
1109+
it('stays silent when no logger is provided (back-compat)', async () => {
1110+
const evaluator = new PermissionEvaluator();
1111+
const metadata = { list: vi.fn().mockReturnValue([]) };
1112+
await expect(
1113+
evaluator.resolvePermissionSets(['x'], metadata, [], async () => { throw new Error('db down'); }),
1114+
).resolves.toEqual([]);
1115+
});
1116+
10721117
it('matches by both role and explicit permission-set identifiers', async () => {
10731118
const evaluator = new PermissionEvaluator();
10741119
const sets = [

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,7 @@ export class SecurityPlugin implements Plugin {
929929
this.metadata,
930930
this.bootstrapPermissionSets,
931931
this.dbLoader,
932+
{ logger: this.logger },
932933
);
933934
// Post-resolution fallback — closes the fail-open hole where a populated
934935
// `roles` array maps to no permission set yet (no sys_role binding), which
@@ -943,6 +944,7 @@ export class SecurityPlugin implements Plugin {
943944
this.metadata,
944945
this.bootstrapPermissionSets,
945946
this.dbLoader,
947+
{ logger: this.logger },
946948
);
947949
}
948950
return permissionSets;

0 commit comments

Comments
 (0)