From 902fbccf251decdf4831380da938c691af27d8af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 09:09:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(security):=20ADR-0056=20D7=20=E2=80=94?= =?UTF-8?q?=20app-declarable=20default=20profile=20(isDefault)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .changeset/adr-0056-d7-default-profile.md | 16 ++++++ .../showcase-default-profile.dogfood.test.ts | 57 +++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 5 +- packages/spec/src/security/permission.zod.ts | 1 + 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .changeset/adr-0056-d7-default-profile.md create mode 100644 packages/dogfood/test/showcase-default-profile.dogfood.test.ts diff --git a/.changeset/adr-0056-d7-default-profile.md b/.changeset/adr-0056-d7-default-profile.md new file mode 100644 index 0000000000..8038f40165 --- /dev/null +++ b/.changeset/adr-0056-d7-default-profile.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +--- + +feat(security): app-declarable default profile (`isDefault`, ADR-0056 D7) + +An app can now declare its default access posture for authenticated users who have +no explicit grants, via `isDefault: true` on a permission set — instead of always +inheriting the built-in `member_default`. The SecurityPlugin resolves the fallback +from the `isDefault` profile when no explicit `fallbackPermissionSet` is configured +(falling back to `member_default` when none is declared — non-breaking). This is the +foundation for SSO/JIT provisioning (mapping IdP claims → a declared default profile). +Proven by the `showcase-default-profile` dogfood test: a sign-up governed by a custom +default that grants only `showcase_announcement` can read it but is denied +`showcase_private_note` (which the `member_default` wildcard would have allowed). diff --git a/packages/dogfood/test/showcase-default-profile.dogfood.test.ts b/packages/dogfood/test/showcase-default-profile.dogfood.test.ts new file mode 100644 index 0000000000..c7f31cf15c --- /dev/null +++ b/packages/dogfood/test/showcase-default-profile.dogfood.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 D7 — app-declared DEFAULT PROFILE. A permission set marked +// `isDefault: true` becomes the fallback for authenticated users with no explicit +// grants — the app declares its default access posture instead of inheriting the +// built-in `member_default`. Proven on the real showcase: a fresh sign-up governed +// by a custom default profile that grants ONLY `showcase_announcement` can read it +// but is DENIED `showcase_private_note` (which the wildcard `member_default` would +// have allowed) — so the declared default is provably in effect. Foundation for +// SSO/JIT provisioning. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +// App-declared default profile — grants ONLY announcement (no wildcard). +const demoDefault = PermissionSetSchema.parse({ + name: 'showcase_demo_default', + label: 'Demo Default Profile', + isProfile: true, + isDefault: true, // ← the D7 marker: this is the fallback for unprovisioned users + objects: { + showcase_announcement: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + }, +}); + +describe('showcase: app-declared default profile (ADR-0056 D7)', () => { + let stack: VerifyStack; + let memberToken: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + // NOTE: no `fallbackPermissionSet` passed — it MUST resolve from `isDefault`. + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, demoDefault], + }), + }); + await stack.signIn(); + memberToken = await stack.signUp('d7-member@verify.test'); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + it('a fresh sign-up is governed by the app-declared default (grants announcement)', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_announcement'); + expect(r.status, 'default profile grants announcement read').toBe(200); + }); + + it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note'); + // member_default has a wildcard grant → would be 200. The declared default + // grants only announcement → this object is denied, proving D7 is in effect. + expect(r.status, 'default profile does NOT grant private_note').not.toBe(200); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 63410c19ec..8881256454 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -116,7 +116,10 @@ export class SecurityPlugin implements Plugin { options.defaultPermissionSets ?? securityDefaultPermissionSets; this.fallbackPermissionSet = options.fallbackPermissionSet === undefined - ? 'member_default' + // ADR-0056 D7: an app may declare its default profile via `isDefault: true` + // on a permission set; it becomes the fallback for users with no explicit + // grants. Falls back to the built-in `member_default` when none is declared. + ? (this.bootstrapPermissionSets.find((p) => (p as { isDefault?: boolean }).isDefault)?.name ?? 'member_default') : options.fallbackPermissionSet; } diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 059f158021..657cb8846f 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -95,6 +95,7 @@ export const PermissionSetSchema = lazySchema(() => z.object({ /** Is this a Profile? (Base set for a user) */ isProfile: z.boolean().default(false).describe('Whether this is a user profile'), + 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.'), /** Object Permissions Map: -> permissions */ objects: z.record(z.string(), ObjectPermissionSchema).describe('Entity permissions'), From 30da6bbd34105c8927030ad8a4181dac83e93379 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 09:12:33 +0000 Subject: [PATCH 2/2] fix(spec): classify permission/isDefault in the liveness registry (ADR-0056 D7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- packages/spec/liveness/permission.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 6b65dcb2f0..cea0b10dfe 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -16,6 +16,11 @@ "evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.tsx:92 (!!d.isProfile)", "note": "LIVE via objectui metadata-admin authoring UI — the 2026-06 audit missed objectui; verified by reading the file." }, + "isDefault": { + "status": "live", + "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).", + "note": "LIVE — app-declared default profile; proven by packages/dogfood/test/showcase-default-profile.dogfood.test.ts." + }, "objects": { "children": { "allowCreate": {