Skip to content

Commit 6d6ed48

Browse files
committed
fix(plugin-security): bind fallback set to everyone AFTER the anchor is seeded
The ADR-0090 D5 baseline auto-bind ran earlier in runBootstrap than bootstrapBuiltinRoles, which creates the everyone position — so the everyone lookup returned nothing and the app's isDefault set (resolved as fallbackPermissionSet) was never bound. A fresh deploy booted with everyone empty (personas silently degraded) plus a redundant sys_audience_binding_suggestion for the same set. Move the auto-bind after bootstrapBuiltinRoles and before syncAudienceBindingSuggestions so the documented app-level auto-bind actually happens and the suggestion sync skips the already-bound set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
1 parent aee1496 commit 6d6ed48

2 files changed

Lines changed: 49 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): bind the fallback permission set to the `everyone` anchor AFTER the anchor is seeded. The baseline auto-bind (ADR-0090 D5) ran earlier in `runBootstrap` than `bootstrapBuiltinRoles`, which creates the `everyone` position — so the `everyone` lookup returned nothing and the app's `isDefault` set was never bound, leaving a fresh deploy's `everyone` empty (personas silently degraded) and a redundant `sys_audience_binding_suggestion` filed for the same set. The auto-bind now runs after `bootstrapBuiltinRoles` and before `syncAudienceBindingSuggestions`, so the documented app-level auto-bind actually happens and the suggestion sync correctly skips the already-bound set.

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

Lines changed: 44 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,44 +1362,9 @@ export class SecurityPlugin implements Plugin {
13621362
ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message });
13631363
}
13641364

1365-
// [ADR-0090 D5] Bind the configured baseline set to the `everyone`
1366-
// audience anchor (idempotent). This makes the CLI/dev fallback
1367-
// (`fallbackPermissionSet` — the app's `isDefault` suggestion) visible
1368-
// as an ordinary position binding: same table, same audit path, same
1369-
// explain surface as any admin-authored default grant. The binding is
1370-
// validated with the SAME high-privilege predicate the write gate
1371-
// enforces — a dangerous baseline is refused loudly, never seeded.
1372-
try {
1373-
if (this.fallbackPermissionSet) {
1374-
const boot = this.bootstrapPermissionSets.find((p) => p.name === this.fallbackPermissionSet);
1375-
const offending = boot ? describeHighPrivilegeBits(boot) : null;
1376-
if (offending) {
1377-
ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', {
1378-
set: this.fallbackPermissionSet, offending,
1379-
});
1380-
} else {
1381-
const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 1, context: { isSystem: true } });
1382-
const everyone: any = Array.isArray(everyoneRows) && everyoneRows[0] ? everyoneRows[0] : null;
1383-
const setRows = await ql.find('sys_permission_set', { where: { name: this.fallbackPermissionSet }, limit: 1, context: { isSystem: true } });
1384-
const set: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null;
1385-
if (everyone?.id && set?.id) {
1386-
const existing = await ql.find('sys_position_permission_set', {
1387-
where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: { isSystem: true },
1388-
});
1389-
if (!(Array.isArray(existing) && existing[0])) {
1390-
await ql.insert('sys_position_permission_set', {
1391-
id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
1392-
position_id: everyone.id,
1393-
permission_set_id: set.id,
1394-
}, { context: { isSystem: true } });
1395-
ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { set: this.fallbackPermissionSet });
1396-
}
1397-
}
1398-
}
1399-
}
1400-
} catch (e) {
1401-
ctx.logger.warn('[security] everyone-anchor baseline binding failed (non-fatal)', { error: (e as Error).message });
1402-
}
1365+
// [ADR-0090 D5] The baseline→`everyone` binding runs LATER — after
1366+
// `bootstrapBuiltinRoles` seeds the `everyone` anchor (the anchor must
1367+
// exist before it can be bound). See `bindFallbackToEveryone` below.
14031368
// [ADR-0086 P2 — 块1] Register the publish-time materializer so a
14041369
// permission set authored/edited through the PACKAGE door (saved as a
14051370
// `permission` draft, then published) lands in sys_permission_set with
@@ -1493,6 +1458,47 @@ export class SecurityPlugin implements Plugin {
14931458
} catch (e) {
14941459
ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message });
14951460
}
1461+
// [ADR-0090 D5] Bind the configured baseline set to the `everyone`
1462+
// audience anchor (idempotent). This makes the CLI/dev fallback
1463+
// (`fallbackPermissionSet` — the app's `isDefault` set) visible as an
1464+
// ordinary position binding: same table, same audit path, same explain
1465+
// surface as any admin-authored default grant. The binding is validated
1466+
// with the SAME high-privilege predicate the write gate enforces — a
1467+
// dangerous baseline is refused loudly, never seeded. MUST run after
1468+
// `bootstrapBuiltinRoles` (which seeds the `everyone` anchor) and before
1469+
// `syncAudienceBindingSuggestions` (so the app's own fallback set is
1470+
// already bound and never generates a redundant pending suggestion).
1471+
try {
1472+
if (this.fallbackPermissionSet) {
1473+
const boot = this.bootstrapPermissionSets.find((p) => p.name === this.fallbackPermissionSet);
1474+
const offending = boot ? describeHighPrivilegeBits(boot) : null;
1475+
if (offending) {
1476+
ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', {
1477+
set: this.fallbackPermissionSet, offending,
1478+
});
1479+
} else {
1480+
const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 1, context: { isSystem: true } });
1481+
const everyone: any = Array.isArray(everyoneRows) && everyoneRows[0] ? everyoneRows[0] : null;
1482+
const setRows = await ql.find('sys_permission_set', { where: { name: this.fallbackPermissionSet }, limit: 1, context: { isSystem: true } });
1483+
const set: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null;
1484+
if (everyone?.id && set?.id) {
1485+
const existing = await ql.find('sys_position_permission_set', {
1486+
where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: { isSystem: true },
1487+
});
1488+
if (!(Array.isArray(existing) && existing[0])) {
1489+
await ql.insert('sys_position_permission_set', {
1490+
id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
1491+
position_id: everyone.id,
1492+
permission_set_id: set.id,
1493+
}, { context: { isSystem: true } });
1494+
ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { set: this.fallbackPermissionSet });
1495+
}
1496+
}
1497+
}
1498+
}
1499+
} catch (e) {
1500+
ctx.logger.warn('[security] everyone-anchor baseline binding failed (non-fatal)', { error: (e as Error).message });
1501+
}
14961502
// [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface:
14971503
// every declared `isDefault: true` set that is not already bound to
14981504
// its anchor becomes a PENDING suggestion row awaiting admin

0 commit comments

Comments
 (0)