Skip to content

Commit 9c82146

Browse files
os-helpclaude
andauthored
fix(security): compose an app-declared baseline WITH the platform member_default (#7555) (#7605)
A permission set marked `isDefault: true` became the deployment's ONLY baseline — `fallbackPermissionSet` was a single name and the app's set went into it — so every member of a baseline-declaring app silently lost the platform floor. Measured on the showcase: all 10 built-in Account nav entries served, 7/7 of the objects behind them 403. ADR-0090 D5 rules the baseline additive without exception ("The fallback cliff is abolished. ... `everyone` is additive like any other position: baseline u explicit, always"). The human baseline is now the list of names it always was: the declared set PLUS `member_default`, deduped, applied in the additive step, the post-resolution fallback, the ADR-0106 D7 metadata-plane resolution, and the boot-time `everyone`-anchor binding. Unchanged on purpose: agent principals keep exactly their D10 restricted ceiling, `null` still disables the baseline entirely, `member_default`'s grant rows and the D5/D9 high-privilege anchor gate are untouched, and an app that declares nothing resolves `['member_default']` exactly as before. Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 22df871 commit 9c82146

12 files changed

Lines changed: 561 additions & 84 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/plugin-hono-server': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(security): an app-declared permission baseline COMPOSES with the platform `member_default` instead of replacing it (#7555)
8+
9+
A permission set marked `isDefault: true` used to become the deployment's ONLY
10+
baseline: `SecurityPlugin`'s `fallbackPermissionSet` held a single name, and an
11+
app's declared set went into it, so every member of that app silently lost the
12+
platform floor. Measured on the showcase (#7555): a fresh member is served all
13+
10 built-in Account nav entries and 7/7 of the objects behind them answer 403,
14+
because `showcase_member_default` names no `sys_*` object and `member_default`
15+
was no longer in force for anyone in that app.
16+
17+
That is the ADR-0090 D5 fallback cliff in its second spelling — D5 rules the
18+
baseline additive without exception ("The fallback cliff is abolished. …
19+
`everyone` is additive like any other position: baseline ∪ explicit, always")
20+
and narrows `isDefault` to a package-authored *suggestion*, "never a runtime
21+
fallback".
22+
23+
The human baseline is now the list of names it always was: the declared set
24+
**plus** the platform `member_default`, deduped. Both are pushed into the
25+
per-request resolution, both back the post-resolution fallback and the ADR-0106
26+
D7 metadata-plane resolution, and both are bound to the `everyone` audience
27+
anchor at boot so `security/explain` and the Setup UI report the default a
28+
request actually applies. The composed list is published as a new
29+
`security.baselinePermissionSets` service, which `/auth/me/permissions` and
30+
`/me/apps` read so the capability and tab surface cannot disagree with the data
31+
plane; `security.fallbackPermissionSet` is unchanged and still means "the single
32+
name this deployment declared".
33+
34+
Deliberately unchanged:
35+
36+
- **Agent principals** keep exactly their ADR-0090 D10 restricted ceiling — the
37+
composed human baseline is unreachable from `principalKind: 'agent'`.
38+
- **`fallbackPermissionSet: null`** still disables the baseline entirely; the
39+
composition never re-adds one.
40+
- **`member_default`'s own grant rows**, the D5/D9 high-privilege anchor-binding
41+
gate, and #5491's narrowing of the platform baseline to explicit-allow.
42+
43+
An app that declares no `isDefault` set resolves `['member_default']` and is
44+
byte-for-byte unaffected.

packages/plugins/plugin-hono-server/src/current-user-endpoints.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,36 @@ function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null):
312312
return v === true || (typeof v === 'object' && v !== null && v.enabled === true);
313313
}
314314

315+
/**
316+
* [#7555, ADR-0090 D5] The baseline permission-set NAMES this deployment
317+
* applies to a human principal — read from SecurityPlugin, never re-derived.
318+
*
319+
* The plugin registers `security.baselinePermissionSets` (the app-declared
320+
* baseline COMPOSED with the platform `member_default`); this file's two
321+
* resolutions must ask for that list rather than the single
322+
* `security.fallbackPermissionSet` name, or an app that declares an `isDefault`
323+
* set gets the pre-#7555 DISPLACEMENT here — its members' capability and tab
324+
* surface computed from the app set alone, disagreeing with the data plane one
325+
* function call away.
326+
*
327+
* The `security.fallbackPermissionSet` read is kept as the fallback for a
328+
* SecurityPlugin too old to register the list, and the bare `member_default`
329+
* default for a stack with no SecurityPlugin at all — both pre-existing
330+
* behaviours, unchanged.
331+
*/
332+
function baselinePermissionSetNames(ctx: { getService: <T>(name: string) => T | undefined }): string[] {
333+
const composed = (() => {
334+
try { return ctx.getService<string[] | undefined>('security.baselinePermissionSets'); }
335+
catch { return undefined; }
336+
})();
337+
if (Array.isArray(composed)) return composed;
338+
const declared: string | null = (() => {
339+
try { return ctx.getService<string | null>('security.fallbackPermissionSet') ?? 'member_default'; }
340+
catch { return 'member_default'; }
341+
})();
342+
return declared ? [declared] : [];
343+
}
344+
315345
/**
316346
* Buckets whose user-context generic writes are guarded fail-closed at the
317347
* engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2),
@@ -665,10 +695,7 @@ export function registerCurrentUserEndpoints(
665695
try { return ctx.getService<any[]>('security.bootstrapPermissionSets') ?? []; }
666696
catch { return []; }
667697
})();
668-
const fallbackName: string | null = (() => {
669-
try { return ctx.getService<string | null>('security.fallbackPermissionSet') ?? 'member_default'; }
670-
catch { return 'member_default'; }
671-
})();
698+
const fallbackNames: string[] = baselinePermissionSetNames(ctx);
672699
// DB loader: surfaces user-defined permission sets
673700
// (created via the admin UI as `sys_permission_set`
674701
// rows) that aren't in metadata or bootstrap.
@@ -737,9 +764,9 @@ export function registerCurrentUserEndpoints(
737764
let resolved: ResolvedPermissionSetLike[] = await evaluator
738765
.resolvePermissionSets(requested, metadata, bootstrap, dbLoader)
739766
.catch(() => []);
740-
if (resolved.length === 0 && fallbackName) {
767+
if (resolved.length === 0 && fallbackNames.length > 0) {
741768
resolved = await evaluator
742-
.resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader)
769+
.resolvePermissionSets(fallbackNames, metadata, bootstrap, dbLoader)
743770
.catch(() => []);
744771
}
745772
// Most-permissive merge of `objects` and `fields` across
@@ -915,10 +942,7 @@ export function registerCurrentUserEndpoints(
915942
try { return ctx.getService<any[]>('security.bootstrapPermissionSets') ?? []; }
916943
catch { return []; }
917944
})();
918-
const fallbackName: string | null = (() => {
919-
try { return ctx.getService<string | null>('security.fallbackPermissionSet') ?? 'member_default'; }
920-
catch { return 'member_default'; }
921-
})();
945+
const fallbackNames: string[] = baselinePermissionSetNames(ctx);
922946
const requested = [
923947
...((execCtx as any).positions ?? []),
924948
...((execCtx as any).permissions ?? []),
@@ -951,9 +975,9 @@ export function registerCurrentUserEndpoints(
951975
let resolved: ResolvedPermissionSetLike[] = await evaluator
952976
.resolvePermissionSets(requested, metadata, bootstrap, dbLoader)
953977
.catch(() => []);
954-
if (resolved.length === 0 && fallbackName) {
978+
if (resolved.length === 0 && fallbackNames.length > 0) {
955979
resolved = await evaluator
956-
.resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader)
980+
.resolvePermissionSets(fallbackNames, metadata, bootstrap, dbLoader)
957981
.catch(() => []);
958982
}
959983
const tabRank: Record<string, number> = { hidden: 0, default_off: 1, default_on: 2, visible: 3 };

packages/plugins/plugin-security/src/app-default-permission-set.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,74 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
/**
4+
* [ADR-0090 D5, #7555] The PLATFORM's own human baseline permission set.
5+
*
6+
* Every authenticated human principal resolves this set in addition to whatever
7+
* else they hold. It is the platform floor: read on the better-auth identity
8+
* tables and self-service on the caller's own preference rows — the grants that
9+
* keep `/auth/me`, the org switcher and the built-in Account app working for a
10+
* member who holds no application profile at all. A platform app's platform
11+
* object belongs here (maintainer ruling, 2026-08-11).
12+
*/
13+
export const PLATFORM_BASELINE_PERMISSION_SET = 'member_default';
14+
15+
/**
16+
* [#7555] The human baseline as the LIST of set names it actually is: the
17+
* app/deployment-declared baseline COMPOSED WITH the platform baseline — never
18+
* one displacing the other.
19+
*
20+
* ADR-0090 D5 rules the baseline additive without exception ("The fallback
21+
* cliff is abolished. … `everyone` is additive like any other position:
22+
* baseline ∪ explicit, always"), and narrows `isDefault` to "a package-authored
23+
* *suggestion* consumed once at install time … never a runtime fallback". The
24+
* interim wiring below (`appSecurityPluginOptions`) nevertheless funnels an
25+
* app's `isDefault` set into a SINGLE `fallbackPermissionSet` name, so
26+
* declaring one silently REPLACED `member_default` for every member of that
27+
* app. That is the D5 cliff in its other spelling — an app-authoring decision
28+
* costing members the entire platform floor — and #7555 measured what it does:
29+
* on the showcase, all 10 built-in Account nav entries are served and 7/7 of
30+
* the objects behind them answer 403, because the declared set names no `sys_*`
31+
* object and `member_default` was no longer in force.
32+
*
33+
* The composition is safe by construction in one direction only, which is the
34+
* direction that matters: the evaluator merges sets most-permissively, so
35+
* adding the platform baseline back can only ADD grants for human principals.
36+
* It is deliberately NOT a way to widen anything else —
37+
*
38+
* • `null` still means "no baseline at all" and composes to `[]`. That is the
39+
* one escape hatch, and it is all-or-nothing on purpose: a deployment that
40+
* wants a floor NARROWER than the platform's states it by unbinding
41+
* `member_default` from the `everyone` anchor (the D5 end state), not by
42+
* naming a different set here.
43+
* • AGENT principals never reach this list at all — ADR-0090 D10 gives them a
44+
* restricted CEILING, not a human floor, and `resolvePermissionSetsForContext`
45+
* branches before it is consulted.
46+
*
47+
* Order is app-set-first, platform-second, and load-bearing only for the
48+
* explain surface's reading order; the merge itself is order-independent.
49+
*/
50+
export function composeHumanBaselinePermissionSets(
51+
configured: string | null | undefined,
52+
): string[] {
53+
if (!configured) return [];
54+
return configured === PLATFORM_BASELINE_PERMISSION_SET
55+
? [PLATFORM_BASELINE_PERMISSION_SET]
56+
: [configured, PLATFORM_BASELINE_PERMISSION_SET];
57+
}
58+
359
/**
460
* ADR-0090 D5 (interim wiring, supersedes ADR-0056 D7) — resolve the
561
* app-declared default permission-set NAME from a stack's `permissions[]`.
662
*
763
* A permission set marked `isDefault` declares the app's suggested default
864
* access posture. Until the built-in `everyone` position lands (ADR-0090 P2),
9-
* the CLI keeps using this name as the runtime fallback for users with no
10-
* explicit grants; P2 replaces the fallback mechanism with an install-time
11-
* suggestion bound to `everyone`.
65+
* the CLI keeps using this name as the app's runtime baseline — composed with
66+
* the platform baseline, never replacing it (see
67+
* {@link composeHumanBaselinePermissionSets}, #7555); P2 replaces the mechanism
68+
* with an install-time suggestion bound to `everyone`.
1269
*
1370
* Returns the first `isDefault` set's `name`, or `undefined` when none is
14-
* declared (callers then keep the built-in `member_default` fallback).
71+
* declared (callers then run on the platform baseline alone).
1572
*/
1673
export function appDefaultPermissionSetName(permissions: unknown): string | undefined {
1774
if (!Array.isArray(permissions)) return undefined;

0 commit comments

Comments
 (0)