Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/suggested-audience-bindings-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/plugin-security": minor
"@objectstack/runtime": minor
"@objectstack/rest": minor
---

ADR-0090 D5/D9 — suggested audience bindings become a queryable, confirmable surface.

A package permission set declaring `isDefault: true` is an install-time
SUGGESTION to bind the set to the built-in `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.

**`@objectstack/plugin-security`**: new `sys_audience_binding_suggestion`
system object (read-only over the data API; unique per
package × set × anchor) plus a convergent reconciler
(`syncAudienceBindingSuggestions`) that reads every declared `isDefault` set —
boot-declared stack metadata AND installed package manifests, so a runtime
`POST /api/v1/packages` install is visible immediately — and keeps the table
honest: undeclared → pending row pruned, bound out-of-band → marked
`confirmed` (observed). The `security` service gains
`listAudienceBindingSuggestions` / `confirmAudienceBindingSuggestion` /
`dismissAudienceBindingSuggestion`, all pre-gated on tenant-level admin
(ADR-0066 superuser wildcard — anchors stay tenant-level only per D12).
Confirm writes the `sys_position_permission_set` row **with the caller's
execution context**, so the D5/D9 audience-anchor gate (no high-privilege
set on `everyone`/`guest`) and the D12 delegated-admin gate enforce the
binding; a set not yet materialized (installed this session) is first
seeded through the same provenance-checked upsert as the boot seeder
(ADR-0086 D4).

**`@objectstack/rest`** and **`@objectstack/runtime`**: the HTTP surface,
registered on both API layers (the RestServer that `objectstack dev`/hono
serves, and the runtime HttpDispatcher used by the adapters) —
`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 mapped from the service's typed errors, 501/503 without
plugin-security).
19 changes: 19 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,25 @@ suggestion individually; nothing auto-binds, and the D7 linter rejects an
`isDefault` set that carries anchor-forbidden bits (VAMA, destructive bits,
wildcards, system permissions).

Pending suggestions are materialized as `sys_audience_binding_suggestion`
rows (one per package × set × anchor, read-only over the data API) and
resolved through the security surface — both installing a package at runtime
and declaring the set in the stack produce them:

```http
GET /api/v1/security/suggested-bindings?status=pending # list (reconciles first)
POST /api/v1/security/suggested-bindings/:id/confirm # create the everyone binding
POST /api/v1/security/suggested-bindings/:id/dismiss # decline the prompt
```

All three require a tenant-level administrator (the anchors are tenant-level
only — D12). Confirm writes the `sys_position_permission_set` row **as the
caller**, so the audience-anchor gate re-checks the forbidden-bits predicate
at the data layer; a suggestion whose binding was created out-of-band (boot
baseline, manual bind) is marked `confirmed` automatically, and uninstalling
the package prunes its pending suggestions. Studio surfaces pending
suggestions after marketplace installs and in the Access pillar.

## Delegated administration — `adminScope` (ADR-0090 D12)

A permission set may carry an `adminScope`, making its holders **scoped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ interface SeedOptions {
* boot path; the metadata-service facade only surfaces these once the
* compiled-artifact loader runs (serve.ts).
*/
function readDeclared(engine: any, type: string): any[] {
export function readDeclared(engine: any, type: string): any[] {
try {
const reg = engine?._registry;
if (reg?.listItems) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function parseMaybeJson(v: unknown): any {
}

/** ADR-0066 tenant-level admin: a resolved set whose '*' entry carries modifyAllRecords. */
function isTenantAdmin(sets: PermissionSet[]): boolean {
export function isTenantAdmin(sets: PermissionSet[]): boolean {
for (const ps of sets) {
const objects: any = parseMaybeJson((ps as any).objects) ?? {};
const wildcard = objects?.['*'];
Expand Down
11 changes: 10 additions & 1 deletion packages/plugins/plugin-security/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js';
export { claimSeedOwnership } from './claim-seed-ownership.js';
export { appDefaultPermissionSetName } from './app-default-permission-set.js';
export { DelegatedAdminGate } from './delegated-admin-gate.js';
export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js';
export { explainAccess, buildContextForUser } from './explain-engine.js';
export type { ExplainEngineDeps, ExplainInput } from './explain-engine.js';
export type { DelegatedAdminGateDeps } from './delegated-admin-gate.js';
export {
syncAudienceBindingSuggestions,
listAudienceBindingSuggestions,
confirmAudienceBindingSuggestion,
dismissAudienceBindingSuggestion,
SuggestionNotFoundError,
SuggestionStateError,
} from './suggested-audience-bindings.js';
export type { SuggestionDeps, SuggestionListFilter, SuggestionSyncOutcome } from './suggested-audience-bindings.js';
2 changes: 2 additions & 0 deletions packages/plugins/plugin-security/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
SysUserPermissionSet,
SysPositionPermissionSet,
SysUserPosition,
SysAudienceBindingSuggestion,
defaultPermissionSets,
} from './objects/index.js';

Expand All @@ -29,6 +30,7 @@ export const securityObjects = [
SysUserPermissionSet,
SysPositionPermissionSet,
SysUserPosition,
SysAudienceBindingSuggestion,
];

/** Default platform permission sets (admin / member / viewer). */
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-security/src/objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export { SysPermissionSet } from './sys-permission-set.object.js';
export { SysUserPermissionSet } from './sys-user-permission-set.object.js';
export { SysPositionPermissionSet } from './sys-position-permission-set.object.js';
export { SysUserPosition } from './sys-user-position.object.js';
export { SysAudienceBindingSuggestion } from './sys-audience-binding-suggestion.object.js';
export { defaultPermissionSets } from './default-permission-sets.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
* sys_audience_binding_suggestion — a package's install-time SUGGESTION to
* bind one of its permission sets to an audience anchor (ADR-0090 D5/D9).
*
* A package declaring `isDefault: true` on a permission set is asking the
* admin: "bind this set to the `everyone` position so authenticated users
* get it by default". It is NEVER auto-bound — installing a package must not
* silently widen every tenant user's access. This table is the queryable
* surface between the two moments: rows are produced (pending) when the
* declaration is observed — boot seeding, package-door publish, or the
* suggested-bindings list endpoint syncing against installed manifests — and
* resolved when a tenant admin confirms (the binding row is created under
* the D5/D9 anchor gate + D12 delegated-admin gate) or dismisses.
*
* Rows are system-managed: the API surface is read-only (get/list); state
* changes flow exclusively through the `security` service confirm/dismiss
* methods so the gates cannot be sidestepped by a generic data write.
*
* @namespace sys
*/
export const SysAudienceBindingSuggestion = ObjectSchema.create({
name: 'sys_audience_binding_suggestion',
label: 'Audience Binding Suggestion',
pluralLabel: 'Audience Binding Suggestions',
icon: 'shield-question',
isSystem: true,
managedBy: 'system',
description: 'Package-suggested audience-anchor binding awaiting admin confirmation (ADR-0090 D5/D9).',
titleFormat: '{package_id}: {permission_set_name} → {anchor}',
highlightFields: ['package_id', 'permission_set_name', 'anchor', 'status'],

fields: {
id: Field.text({
label: 'Suggestion ID',
required: true,
readonly: true,
description: 'UUID of the suggestion row.',
}),

package_id: Field.text({
label: 'Package',
required: true,
readonly: true,
description: 'Owning package that ships the suggested permission set (ADR-0086 D3 provenance).',
}),

permission_set_name: Field.text({
label: 'Permission Set',
required: true,
readonly: true,
description: 'Name of the suggested permission set (resolved against sys_permission_set at confirm time).',
}),

anchor: Field.select({
label: 'Audience Anchor',
required: true,
readonly: true,
defaultValue: 'everyone',
description: 'Audience anchor position the package suggests binding to (ADR-0090 D9).',
options: [
{ value: 'everyone', label: 'Everyone' },
{ value: 'guest', label: 'Guest' },
],
}),

status: Field.select({
label: 'Status',
required: true,
defaultValue: 'pending',
description: 'pending = awaiting admin decision; confirmed = binding exists; dismissed = admin declined.',
options: [
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'dismissed', label: 'Dismissed' },
],
}),

resolved_by: Field.lookup('sys_user', {
label: 'Resolved By',
required: false,
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.',
}),

resolved_at: Field.datetime({
label: 'Resolved At',
required: false,
description: 'When the suggestion left the pending state.',
}),

created_at: Field.datetime({
label: 'Created At',
defaultValue: 'NOW()',
readonly: true,
}),

updated_at: Field.datetime({
label: 'Updated At',
defaultValue: 'NOW()',
readonly: true,
}),
},

indexes: [
{ fields: ['package_id', 'permission_set_name', 'anchor'], unique: true },
{ fields: ['status'] },
{ fields: ['package_id'] },
],

enable: {
trackHistory: true,
searchable: false,
apiEnabled: true,
// Read-only over the generic data API — confirm/dismiss go through the
// `security` service so the anchor + delegated-admin gates always apply.
apiMethods: ['get', 'list'],
trash: false,
mru: false,
},
});
45 changes: 44 additions & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import { explainAccess, buildContextForUser } from './explain-engine.js';
import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security';
import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js';
import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js';
import {
syncAudienceBindingSuggestions,
listAudienceBindingSuggestions,
confirmAudienceBindingSuggestion,
dismissAudienceBindingSuggestion,
type SuggestionDeps,
type SuggestionListFilter,
} from './suggested-audience-bindings.js';
import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js';
import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js';
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
Expand Down Expand Up @@ -386,15 +394,33 @@ export class SecurityPlugin implements Plugin {
// service only once the metadata/ql/dbLoader handles are wired (above), so
// a degraded start never exposes a half-initialised resolver.
try {
// [ADR-0090 D5/D9] Suggested audience bindings — shared deps for the
// list/confirm/dismiss surface (same set resolution as the middleware
// and the delegated-admin gate, so admin-ness can never drift).
const suggestionDeps: SuggestionDeps = {
ql,
metadata,
resolveSets: (context: any) => this.resolvePermissionSetsForContext(context),
logger: ctx.logger,
};
ctx.registerService('security', {
getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context),
// [ADR-0090 D6] First-class access explanation. Same code paths as
// the middleware (resolution/evaluator/RLS compiler) — explained by
// construction. Explaining ANOTHER user requires `manage_users`.
explain: (request: { object: string; operation: string; userId?: string }, callerContext?: any) =>
this.explainAccessForCaller(request, callerContext),
// [ADR-0090 D5/D9] Install-time suggestion surface: packages suggest
// audience-anchor bindings; a tenant admin confirms (the binding is
// written under the anchor + delegated-admin gates) or dismisses.
listAudienceBindingSuggestions: (callerContext?: any, filter?: SuggestionListFilter) =>
listAudienceBindingSuggestions(suggestionDeps, callerContext, filter),
confirmAudienceBindingSuggestion: (callerContext: any, id: string) =>
confirmAudienceBindingSuggestion(suggestionDeps, callerContext, id),
dismissAudienceBindingSuggestion: (callerContext: any, id: string) =>
dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id),
});
ctx.logger.info('[security] registered "security" service (getReadFilter, explain) — ADR-0021 D-C / ADR-0090 D6');
ctx.logger.info('[security] registered "security" service (getReadFilter, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9');
} catch (e) {
ctx.logger.warn?.('[security] failed to register "security" service', {
error: (e as Error).message,
Expand Down Expand Up @@ -951,6 +977,12 @@ export class SecurityPlugin implements Plugin {
async (args: { body: unknown; packageId: string | null }) => {
const r = await upsertPackagePermissionSet(ql, args.body, args.packageId, ctx.logger);
const applied = r.seeded + r.updated;
// [ADR-0090 D5] A published set carrying the install-time
// suggestion flag surfaces (or retires) its pending
// suggestion row right away — same convergent sync as boot.
if (applied > 0 && (args.body as { isDefault?: boolean } | null)?.isDefault !== undefined) {
try { await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger); } catch { /* non-fatal */ }
}
// A publish that materialized nothing did NOT go live — report it
// as a failure with the reason so the package-door UI never shows
// a clean publish over a set the admin surface can't see (ADR-0049
Expand Down Expand Up @@ -981,6 +1013,17 @@ export class SecurityPlugin implements Plugin {
} catch (e) {
ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message });
}
// [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface:
// every declared `isDefault: true` set that is not already bound to
// its anchor becomes a PENDING suggestion row awaiting admin
// confirmation — never auto-bound. Runs after the anchors are seeded
// and after the baseline binding above, so the app's own fallback set
// (already bound) never nags.
try {
await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger);
} catch (e) {
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
}
// [ADR-0066 D1] Back-compat seed the capability registry (sys_capability)
// from the curated platform set + the default grants' systemPermissions.
try {
Expand Down
Loading