Skip to content

Commit 6595b53

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0056 D7 — app-declarable default profile (isDefault) (#2070)
* feat(security): ADR-0056 D7 — app-declarable default profile (isDefault) Add `isDefault` to PermissionSetSchema; the SecurityPlugin resolves the fallback permission set from an app-declared `isDefault` profile when no explicit fallbackPermissionSet is configured (else member_default — non-breaking). Foundation for SSO/JIT provisioning. Proven by showcase-default-profile (2/2): a signup governed by a custom default grants announcement but denies private_note (which member_default's wildcard would allow), proving the declared default is active. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * fix(spec): classify permission/isDefault in the liveness registry (ADR-0056 D7) The new isDefault property on PermissionSetSchema is a governed-type property and must be classified for the spec-liveness gate. Marked live — the SecurityPlugin constructor reads it to resolve the fallback profile; proven by the showcase-default-profile dogfood test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2365d07 commit 6595b53

5 files changed

Lines changed: 83 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
feat(security): app-declarable default profile (`isDefault`, ADR-0056 D7)
7+
8+
An app can now declare its default access posture for authenticated users who have
9+
no explicit grants, via `isDefault: true` on a permission set — instead of always
10+
inheriting the built-in `member_default`. The SecurityPlugin resolves the fallback
11+
from the `isDefault` profile when no explicit `fallbackPermissionSet` is configured
12+
(falling back to `member_default` when none is declared — non-breaking). This is the
13+
foundation for SSO/JIT provisioning (mapping IdP claims → a declared default profile).
14+
Proven by the `showcase-default-profile` dogfood test: a sign-up governed by a custom
15+
default that grants only `showcase_announcement` can read it but is denied
16+
`showcase_private_note` (which the `member_default` wildcard would have allowed).
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 D7 — app-declared DEFAULT PROFILE. A permission set marked
4+
// `isDefault: true` becomes the fallback for authenticated users with no explicit
5+
// grants — the app declares its default access posture instead of inheriting the
6+
// built-in `member_default`. Proven on the real showcase: a fresh sign-up governed
7+
// by a custom default profile that grants ONLY `showcase_announcement` can read it
8+
// but is DENIED `showcase_private_note` (which the wildcard `member_default` would
9+
// have allowed) — so the declared default is provably in effect. Foundation for
10+
// SSO/JIT provisioning.
11+
12+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
13+
import showcaseStack from '@objectstack/example-showcase';
14+
import { bootStack, type VerifyStack } from '@objectstack/verify';
15+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
16+
import { PermissionSetSchema } from '@objectstack/spec/security';
17+
18+
// App-declared default profile — grants ONLY announcement (no wildcard).
19+
const demoDefault = PermissionSetSchema.parse({
20+
name: 'showcase_demo_default',
21+
label: 'Demo Default Profile',
22+
isProfile: true,
23+
isDefault: true, // ← the D7 marker: this is the fallback for unprovisioned users
24+
objects: {
25+
showcase_announcement: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
26+
},
27+
});
28+
29+
describe('showcase: app-declared default profile (ADR-0056 D7)', () => {
30+
let stack: VerifyStack;
31+
let memberToken: string;
32+
33+
beforeAll(async () => {
34+
stack = await bootStack(showcaseStack, {
35+
// NOTE: no `fallbackPermissionSet` passed — it MUST resolve from `isDefault`.
36+
security: new SecurityPlugin({
37+
defaultPermissionSets: [...securityDefaultPermissionSets, demoDefault],
38+
}),
39+
});
40+
await stack.signIn();
41+
memberToken = await stack.signUp('d7-member@verify.test');
42+
}, 60_000);
43+
44+
afterAll(async () => { await stack?.stop(); });
45+
46+
it('a fresh sign-up is governed by the app-declared default (grants announcement)', async () => {
47+
const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_announcement');
48+
expect(r.status, 'default profile grants announcement read').toBe(200);
49+
});
50+
51+
it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => {
52+
const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note');
53+
// member_default has a wildcard grant → would be 200. The declared default
54+
// grants only announcement → this object is denied, proving D7 is in effect.
55+
expect(r.status, 'default profile does NOT grant private_note').not.toBe(200);
56+
});
57+
});

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ export class SecurityPlugin implements Plugin {
116116
options.defaultPermissionSets ?? securityDefaultPermissionSets;
117117
this.fallbackPermissionSet =
118118
options.fallbackPermissionSet === undefined
119-
? 'member_default'
119+
// ADR-0056 D7: an app may declare its default profile via `isDefault: true`
120+
// on a permission set; it becomes the fallback for users with no explicit
121+
// grants. Falls back to the built-in `member_default` when none is declared.
122+
? (this.bootstrapPermissionSets.find((p) => (p as { isDefault?: boolean }).isDefault)?.name ?? 'member_default')
120123
: options.fallbackPermissionSet;
121124
}
122125

packages/spec/liveness/permission.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
"evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.tsx:92 (!!d.isProfile)",
1717
"note": "LIVE via objectui metadata-admin authoring UI — the 2026-06 audit missed objectui; verified by reading the file."
1818
},
19+
"isDefault": {
20+
"status": "live",
21+
"evidence": "plugin-security: packages/plugins/plugin-security/src/security-plugin.ts (constructor) — when no explicit fallbackPermissionSet is configured, the SecurityPlugin resolves the fallback from the permission set marked isDefault:true (ADR-0056 D7).",
22+
"note": "LIVE — app-declared default profile; proven by packages/dogfood/test/showcase-default-profile.dogfood.test.ts."
23+
},
1924
"objects": {
2025
"children": {
2126
"allowCreate": {

packages/spec/src/security/permission.zod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export const PermissionSetSchema = lazySchema(() => z.object({
9595

9696
/** Is this a Profile? (Base set for a user) */
9797
isProfile: z.boolean().default(false).describe('Whether this is a user profile'),
98+
isDefault: z.boolean().default(false).describe('[ADR-0056 D7] When true, this profile is the FALLBACK assigned to authenticated users who have no explicit grants — an app declares its default access posture here instead of relying on the built-in member_default. Foundation for SSO/JIT provisioning.'),
9899

99100
/** Object Permissions Map: <entity_name> -> permissions */
100101
objects: z.record(z.string(), ObjectPermissionSchema).describe('Entity permissions'),

0 commit comments

Comments
 (0)