Skip to content

Commit bd39dc5

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0090 D5/D9 suggested-audience-binding surface + confirm/dismiss API (#2746)
* feat(security): queryable suggested-audience-binding surface + confirm/dismiss API (ADR-0090 D5/D9) A package permission set declaring isDefault: true is an install-time SUGGESTION to bind the set to the builtin everyone position — never auto-bound. Until now the flag was only read at bootstrap as the fallback-set name; after an install there was no way to see or act on the suggestion (framework#2696 remaining item). plugin-security: - new sys_audience_binding_suggestion system object (read-only data API, unique per package × set × anchor) - convergent reconciler syncAudienceBindingSuggestions reading declared stack metadata AND installed package manifests (a runtime POST /api/v1/packages install is visible immediately); prunes pending rows on uninstall, marks out-of-band bindings confirmed (observed); runs at boot, after a permission publish, and on every list call - security service gains listAudienceBindingSuggestions / confirmAudienceBindingSuggestion / dismissAudienceBindingSuggestion, all pre-gated on tenant-level admin (anchors stay tenant-level, D12); confirm inserts sys_position_permission_set WITH the caller's execution context so the D5/D9 anchor gate and D12 delegated-admin gate enforce the write; not-yet-materialized sets are seeded through the provenance-checked upsert (ADR-0086 D4) runtime: - GET /api/v1/security/suggested-bindings?status=&packageId= - POST /api/v1/security/suggested-bindings/:id/confirm - POST /api/v1/security/suggested-bindings/:id/dismiss (401 unauthenticated; 403/404/409 from typed service errors; 503 without plugin-security) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DP13MuGwFWLcVxVtuA7rgq * docs(permissions): document the suggested-audience-binding surface (ADR-0090 D5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DP13MuGwFWLcVxVtuA7rgq * fix(runtime): drop redundant slash-trim regex on request path (CodeQL polynomial-redos) split('/')+filter(Boolean) already discards empty segments; the ^\/+|\/+$ regex over request-controlled input was flagged by CodeQL as polynomial backtracking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DP13MuGwFWLcVxVtuA7rgq * feat(rest): serve /security/suggested-bindings on the RestServer surface (ADR-0090 D5/D9) The hono dev/production stack serves /api/v1 through @objectstack/rest, not the runtime HttpDispatcher, so the suggested-bindings routes must be registered there too (same pattern as sharing rules: a security service provider injected by rest-api-plugin, resolveExecCtx + enforceAuth per request, 501 when plugin-security is absent, typed 403/404/409 mapping). Verified end-to-end against a fresh showcase run: anonymous list → 401; admin list → the showcase's second isDefault set (showcase_member_default) appears as a pending suggestion; confirm → 200 with the binding row created by the caller (not system) in sys_position_permission_set; re-confirm → 409. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DP13MuGwFWLcVxVtuA7rgq --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent afa8115 commit bd39dc5

15 files changed

Lines changed: 777 additions & 5 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/rest": minor
5+
---
6+
7+
ADR-0090 D5/D9 — suggested audience bindings become a queryable, confirmable surface.
8+
9+
A package permission set declaring `isDefault: true` is an install-time
10+
SUGGESTION to bind the set to the built-in `everyone` position — never
11+
auto-bound. Until now the flag was only read at bootstrap as the fallback-set
12+
name; after an install there was no way to see or act on the suggestion.
13+
14+
**`@objectstack/plugin-security`**: new `sys_audience_binding_suggestion`
15+
system object (read-only over the data API; unique per
16+
package × set × anchor) plus a convergent reconciler
17+
(`syncAudienceBindingSuggestions`) that reads every declared `isDefault` set —
18+
boot-declared stack metadata AND installed package manifests, so a runtime
19+
`POST /api/v1/packages` install is visible immediately — and keeps the table
20+
honest: undeclared → pending row pruned, bound out-of-band → marked
21+
`confirmed` (observed). The `security` service gains
22+
`listAudienceBindingSuggestions` / `confirmAudienceBindingSuggestion` /
23+
`dismissAudienceBindingSuggestion`, all pre-gated on tenant-level admin
24+
(ADR-0066 superuser wildcard — anchors stay tenant-level only per D12).
25+
Confirm writes the `sys_position_permission_set` row **with the caller's
26+
execution context**, so the D5/D9 audience-anchor gate (no high-privilege
27+
set on `everyone`/`guest`) and the D12 delegated-admin gate enforce the
28+
binding; a set not yet materialized (installed this session) is first
29+
seeded through the same provenance-checked upsert as the boot seeder
30+
(ADR-0086 D4).
31+
32+
**`@objectstack/rest`** and **`@objectstack/runtime`**: the HTTP surface,
33+
registered on both API layers (the RestServer that `objectstack dev`/hono
34+
serves, and the runtime HttpDispatcher used by the adapters) —
35+
`GET /api/v1/security/suggested-bindings?status=&packageId=`,
36+
`POST /api/v1/security/suggested-bindings/:id/confirm`,
37+
`POST /api/v1/security/suggested-bindings/:id/dismiss` (401 unauthenticated,
38+
403/404/409 mapped from the service's typed errors, 501/503 without
39+
plugin-security).

content/docs/permissions/permission-sets.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,25 @@ suggestion individually; nothing auto-binds, and the D7 linter rejects an
137137
`isDefault` set that carries anchor-forbidden bits (VAMA, destructive bits,
138138
wildcards, system permissions).
139139

140+
Pending suggestions are materialized as `sys_audience_binding_suggestion`
141+
rows (one per package × set × anchor, read-only over the data API) and
142+
resolved through the security surface — both installing a package at runtime
143+
and declaring the set in the stack produce them:
144+
145+
```http
146+
GET /api/v1/security/suggested-bindings?status=pending # list (reconciles first)
147+
POST /api/v1/security/suggested-bindings/:id/confirm # create the everyone binding
148+
POST /api/v1/security/suggested-bindings/:id/dismiss # decline the prompt
149+
```
150+
151+
All three require a tenant-level administrator (the anchors are tenant-level
152+
only — D12). Confirm writes the `sys_position_permission_set` row **as the
153+
caller**, so the audience-anchor gate re-checks the forbidden-bits predicate
154+
at the data layer; a suggestion whose binding was created out-of-band (boot
155+
baseline, manual bind) is marked `confirmed` automatically, and uninstalling
156+
the package prunes its pending suggestions. Studio surfaces pending
157+
suggestions after marketplace installs and in the Access pillar.
158+
140159
## Delegated administration — `adminScope` (ADR-0090 D12)
141160

142161
A permission set may carry an `adminScope`, making its holders **scoped

packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ interface SeedOptions {
6161
* boot path; the metadata-service facade only surfaces these once the
6262
* compiled-artifact loader runs (serve.ts).
6363
*/
64-
function readDeclared(engine: any, type: string): any[] {
64+
export function readDeclared(engine: any, type: string): any[] {
6565
try {
6666
const reg = engine?._registry;
6767
if (reg?.listItems) {

packages/plugins/plugin-security/src/delegated-admin-gate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ function parseMaybeJson(v: unknown): any {
7777
}
7878

7979
/** ADR-0066 tenant-level admin: a resolved set whose '*' entry carries modifyAllRecords. */
80-
function isTenantAdmin(sets: PermissionSet[]): boolean {
80+
export function isTenantAdmin(sets: PermissionSet[]): boolean {
8181
for (const ps of sets) {
8282
const objects: any = parseMaybeJson((ps as any).objects) ?? {};
8383
const wildcard = objects?.['*'];

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,16 @@ export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
2828
export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js';
2929
export { claimSeedOwnership } from './claim-seed-ownership.js';
3030
export { appDefaultPermissionSetName } from './app-default-permission-set.js';
31-
export { DelegatedAdminGate } from './delegated-admin-gate.js';
31+
export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js';
3232
export { explainAccess, buildContextForUser } from './explain-engine.js';
3333
export type { ExplainEngineDeps, ExplainInput } from './explain-engine.js';
3434
export type { DelegatedAdminGateDeps } from './delegated-admin-gate.js';
35+
export {
36+
syncAudienceBindingSuggestions,
37+
listAudienceBindingSuggestions,
38+
confirmAudienceBindingSuggestion,
39+
dismissAudienceBindingSuggestion,
40+
SuggestionNotFoundError,
41+
SuggestionStateError,
42+
} from './suggested-audience-bindings.js';
43+
export type { SuggestionDeps, SuggestionListFilter, SuggestionSyncOutcome } from './suggested-audience-bindings.js';

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
SysUserPermissionSet,
1616
SysPositionPermissionSet,
1717
SysUserPosition,
18+
SysAudienceBindingSuggestion,
1819
defaultPermissionSets,
1920
} from './objects/index.js';
2021

@@ -29,6 +30,7 @@ export const securityObjects = [
2930
SysUserPermissionSet,
3031
SysPositionPermissionSet,
3132
SysUserPosition,
33+
SysAudienceBindingSuggestion,
3234
];
3335

3436
/** Default platform permission sets (admin / member / viewer). */

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export { SysPermissionSet } from './sys-permission-set.object.js';
1515
export { SysUserPermissionSet } from './sys-user-permission-set.object.js';
1616
export { SysPositionPermissionSet } from './sys-position-permission-set.object.js';
1717
export { SysUserPosition } from './sys-user-position.object.js';
18+
export { SysAudienceBindingSuggestion } from './sys-audience-binding-suggestion.object.js';
1819
export { defaultPermissionSets } from './default-permission-sets.js';
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_audience_binding_suggestion — a package's install-time SUGGESTION to
7+
* bind one of its permission sets to an audience anchor (ADR-0090 D5/D9).
8+
*
9+
* A package declaring `isDefault: true` on a permission set is asking the
10+
* admin: "bind this set to the `everyone` position so authenticated users
11+
* get it by default". It is NEVER auto-bound — installing a package must not
12+
* silently widen every tenant user's access. This table is the queryable
13+
* surface between the two moments: rows are produced (pending) when the
14+
* declaration is observed — boot seeding, package-door publish, or the
15+
* suggested-bindings list endpoint syncing against installed manifests — and
16+
* resolved when a tenant admin confirms (the binding row is created under
17+
* the D5/D9 anchor gate + D12 delegated-admin gate) or dismisses.
18+
*
19+
* Rows are system-managed: the API surface is read-only (get/list); state
20+
* changes flow exclusively through the `security` service confirm/dismiss
21+
* methods so the gates cannot be sidestepped by a generic data write.
22+
*
23+
* @namespace sys
24+
*/
25+
export const SysAudienceBindingSuggestion = ObjectSchema.create({
26+
name: 'sys_audience_binding_suggestion',
27+
label: 'Audience Binding Suggestion',
28+
pluralLabel: 'Audience Binding Suggestions',
29+
icon: 'shield-question',
30+
isSystem: true,
31+
managedBy: 'system',
32+
description: 'Package-suggested audience-anchor binding awaiting admin confirmation (ADR-0090 D5/D9).',
33+
titleFormat: '{package_id}: {permission_set_name} → {anchor}',
34+
highlightFields: ['package_id', 'permission_set_name', 'anchor', 'status'],
35+
36+
fields: {
37+
id: Field.text({
38+
label: 'Suggestion ID',
39+
required: true,
40+
readonly: true,
41+
description: 'UUID of the suggestion row.',
42+
}),
43+
44+
package_id: Field.text({
45+
label: 'Package',
46+
required: true,
47+
readonly: true,
48+
description: 'Owning package that ships the suggested permission set (ADR-0086 D3 provenance).',
49+
}),
50+
51+
permission_set_name: Field.text({
52+
label: 'Permission Set',
53+
required: true,
54+
readonly: true,
55+
description: 'Name of the suggested permission set (resolved against sys_permission_set at confirm time).',
56+
}),
57+
58+
anchor: Field.select({
59+
label: 'Audience Anchor',
60+
required: true,
61+
readonly: true,
62+
defaultValue: 'everyone',
63+
description: 'Audience anchor position the package suggests binding to (ADR-0090 D9).',
64+
options: [
65+
{ value: 'everyone', label: 'Everyone' },
66+
{ value: 'guest', label: 'Guest' },
67+
],
68+
}),
69+
70+
status: Field.select({
71+
label: 'Status',
72+
required: true,
73+
defaultValue: 'pending',
74+
description: 'pending = awaiting admin decision; confirmed = binding exists; dismissed = admin declined.',
75+
options: [
76+
{ value: 'pending', label: 'Pending' },
77+
{ value: 'confirmed', label: 'Confirmed' },
78+
{ value: 'dismissed', label: 'Dismissed' },
79+
],
80+
}),
81+
82+
resolved_by: Field.lookup('sys_user', {
83+
label: 'Resolved By',
84+
required: false,
85+
description: 'Admin who confirmed/dismissed. Empty on a confirmed row means the binding was observed (e.g. bound at boot or by hand), not confirmed through the prompt.',
86+
}),
87+
88+
resolved_at: Field.datetime({
89+
label: 'Resolved At',
90+
required: false,
91+
description: 'When the suggestion left the pending state.',
92+
}),
93+
94+
created_at: Field.datetime({
95+
label: 'Created At',
96+
defaultValue: 'NOW()',
97+
readonly: true,
98+
}),
99+
100+
updated_at: Field.datetime({
101+
label: 'Updated At',
102+
defaultValue: 'NOW()',
103+
readonly: true,
104+
}),
105+
},
106+
107+
indexes: [
108+
{ fields: ['package_id', 'permission_set_name', 'anchor'], unique: true },
109+
{ fields: ['status'] },
110+
{ fields: ['package_id'] },
111+
],
112+
113+
enable: {
114+
trackHistory: true,
115+
searchable: false,
116+
apiEnabled: true,
117+
// Read-only over the generic data API — confirm/dismiss go through the
118+
// `security` service so the anchor + delegated-admin gates always apply.
119+
apiMethods: ['get', 'list'],
120+
trash: false,
121+
mru: false,
122+
},
123+
});

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import { explainAccess, buildContextForUser } from './explain-engine.js';
99
import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security';
1010
import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js';
1111
import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js';
12+
import {
13+
syncAudienceBindingSuggestions,
14+
listAudienceBindingSuggestions,
15+
confirmAudienceBindingSuggestion,
16+
dismissAudienceBindingSuggestion,
17+
type SuggestionDeps,
18+
type SuggestionListFilter,
19+
} from './suggested-audience-bindings.js';
1220
import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js';
1321
import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js';
1422
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
@@ -386,15 +394,33 @@ export class SecurityPlugin implements Plugin {
386394
// service only once the metadata/ql/dbLoader handles are wired (above), so
387395
// a degraded start never exposes a half-initialised resolver.
388396
try {
397+
// [ADR-0090 D5/D9] Suggested audience bindings — shared deps for the
398+
// list/confirm/dismiss surface (same set resolution as the middleware
399+
// and the delegated-admin gate, so admin-ness can never drift).
400+
const suggestionDeps: SuggestionDeps = {
401+
ql,
402+
metadata,
403+
resolveSets: (context: any) => this.resolvePermissionSetsForContext(context),
404+
logger: ctx.logger,
405+
};
389406
ctx.registerService('security', {
390407
getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context),
391408
// [ADR-0090 D6] First-class access explanation. Same code paths as
392409
// the middleware (resolution/evaluator/RLS compiler) — explained by
393410
// construction. Explaining ANOTHER user requires `manage_users`.
394411
explain: (request: { object: string; operation: string; userId?: string }, callerContext?: any) =>
395412
this.explainAccessForCaller(request, callerContext),
413+
// [ADR-0090 D5/D9] Install-time suggestion surface: packages suggest
414+
// audience-anchor bindings; a tenant admin confirms (the binding is
415+
// written under the anchor + delegated-admin gates) or dismisses.
416+
listAudienceBindingSuggestions: (callerContext?: any, filter?: SuggestionListFilter) =>
417+
listAudienceBindingSuggestions(suggestionDeps, callerContext, filter),
418+
confirmAudienceBindingSuggestion: (callerContext: any, id: string) =>
419+
confirmAudienceBindingSuggestion(suggestionDeps, callerContext, id),
420+
dismissAudienceBindingSuggestion: (callerContext: any, id: string) =>
421+
dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id),
396422
});
397-
ctx.logger.info('[security] registered "security" service (getReadFilter, explain) — ADR-0021 D-C / ADR-0090 D6');
423+
ctx.logger.info('[security] registered "security" service (getReadFilter, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9');
398424
} catch (e) {
399425
ctx.logger.warn?.('[security] failed to register "security" service', {
400426
error: (e as Error).message,
@@ -951,6 +977,12 @@ export class SecurityPlugin implements Plugin {
951977
async (args: { body: unknown; packageId: string | null }) => {
952978
const r = await upsertPackagePermissionSet(ql, args.body, args.packageId, ctx.logger);
953979
const applied = r.seeded + r.updated;
980+
// [ADR-0090 D5] A published set carrying the install-time
981+
// suggestion flag surfaces (or retires) its pending
982+
// suggestion row right away — same convergent sync as boot.
983+
if (applied > 0 && (args.body as { isDefault?: boolean } | null)?.isDefault !== undefined) {
984+
try { await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger); } catch { /* non-fatal */ }
985+
}
954986
// A publish that materialized nothing did NOT go live — report it
955987
// as a failure with the reason so the package-door UI never shows
956988
// a clean publish over a set the admin surface can't see (ADR-0049
@@ -981,6 +1013,17 @@ export class SecurityPlugin implements Plugin {
9811013
} catch (e) {
9821014
ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message });
9831015
}
1016+
// [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface:
1017+
// every declared `isDefault: true` set that is not already bound to
1018+
// its anchor becomes a PENDING suggestion row awaiting admin
1019+
// confirmation — never auto-bound. Runs after the anchors are seeded
1020+
// and after the baseline binding above, so the app's own fallback set
1021+
// (already bound) never nags.
1022+
try {
1023+
await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger);
1024+
} catch (e) {
1025+
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
1026+
}
9841027
// [ADR-0066 D1] Back-compat seed the capability registry (sys_capability)
9851028
// from the curated platform set + the default grants' systemPermissions.
9861029
try {

0 commit comments

Comments
 (0)