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
16 changes: 16 additions & 0 deletions .changeset/adr-0056-d7-default-profile.md
Original file line number Diff line number Diff line change
@@ -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).
57 changes: 57 additions & 0 deletions packages/dogfood/test/showcase-default-profile.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 4 additions & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/permission.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/security/permission.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <entity_name> -> permissions */
objects: z.record(z.string(), ObjectPermissionSchema).describe('Entity permissions'),
Expand Down
Loading