Skip to content

Commit 20022b7

Browse files
committed
feat(security): wire app-declared default profile through the CLI (ADR-0056 D7)
D7 added `isDefault` to PermissionSet and resolves it in the SecurityPlugin constructor — but only over the plugin's built-in `defaultPermissionSets`. The CLI boots `new SecurityPlugin()` with no options, so an `isDefault` profile declared purely in APP METADATA was silently ignored under `pnpm dev`. This closes that gap: - `appDefaultProfileName(permissions)` — exported helper that extracts the first `isProfile && isDefault` profile name from a stack's permissions. - CLI `serve.ts` calls it and passes the name as `fallbackPermissionSet` (undefined → built-in default preserved, so non-declaring apps are unaffected). At request time the name resolves through the metadata service / `sys_permission_set` like any user-defined permission set. - Showcase declares `showcase_member_default` (isDefault) — a read-mostly baseline for fresh sign-ups, so `pnpm dev` demonstrates an app-declared default posture instead of the built-in wildcard. Proofs: unit test for `appDefaultProfileName`, and a showcase dogfood test that wires the extracted name as the fallback and shows a fresh member is governed by the declared default (reads announcements, denied private notes the built-in wildcard would have allowed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 034b867 commit 20022b7

6 files changed

Lines changed: 165 additions & 3 deletions

File tree

examples/app-showcase/src/security/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,33 @@ export const ContributorPermissionSet = {
8383
],
8484
};
8585

86+
// ── App-declared DEFAULT PROFILE (ADR-0056 D7) ──────────────────────────────
87+
/**
88+
* The showcase's default access posture for a freshly signed-up user who holds
89+
* no explicit grants. `isDefault: true` makes the app declare what "a new member
90+
* can do" instead of inheriting the built-in `member_default` wildcard. The CLI
91+
* (`pnpm dev`) reads this off the stack and wires it as the SecurityPlugin
92+
* fallback (ADR-0056 D7) — without that wiring an `isDefault` flag in app
93+
* metadata is silently ignored. Deliberately read-mostly: a brand-new member can
94+
* browse the shared catalog + announcements and file tasks/inquiries, but cannot
95+
* edit or delete anyone's records (owner/OWD enforcement still applies on top).
96+
*/
97+
export const MemberDefaultProfile = {
98+
name: 'showcase_member_default',
99+
label: 'Showcase Member (Default)',
100+
description: 'App-declared default profile for new sign-ups — read-mostly baseline (ADR-0056 D7).',
101+
isProfile: true,
102+
isDefault: true,
103+
objects: {
104+
showcase_account: { allowRead: true },
105+
showcase_product: { allowRead: true },
106+
showcase_project: { allowRead: true },
107+
showcase_task: { allowRead: true, allowCreate: true },
108+
showcase_announcement: { allowRead: true },
109+
showcase_inquiry: { allowRead: true, allowCreate: true },
110+
},
111+
};
112+
86113
// ── Sharing rules ──────────────────────────────────────────────────────────
87114
/** criteria-based: red-health projects are shared up to executives. */
88115
export const RedProjectSharingRule = {
@@ -128,6 +155,6 @@ export const ShowcasePolicy = {
128155
};
129156

130157
export const allRoles = [ContributorRole, ManagerRole, ExecRole];
131-
export const allPermissionSets = [ContributorPermissionSet];
158+
export const allPermissionSets = [ContributorPermissionSet, MemberDefaultProfile];
132159
export const allSharingRules = [RedProjectSharingRule, ContributorTaskSharingRule];
133160
export const allPolicies = [ShowcasePolicy];

packages/cli/src/commands/serve.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,8 +1297,14 @@ export default class Serve extends Command {
12971297
// Pair: SecurityPlugin (RBAC) — optional
12981298
try {
12991299
const securityPkg = '@objectstack/plugin-security';
1300-
const { SecurityPlugin } = await import(/* webpackIgnore: true */ securityPkg);
1301-
await kernel.use(new SecurityPlugin());
1300+
const { SecurityPlugin, appDefaultProfileName } = await import(/* webpackIgnore: true */ securityPkg);
1301+
// ADR-0056 D7 — honor an app-declared default profile. A stack
1302+
// permission set marked `isProfile && isDefault` becomes the
1303+
// fallback for users with no explicit grants. The SecurityPlugin's
1304+
// own scan only sees its built-in sets, so the CLI passes the
1305+
// declared name through explicitly (undefined → built-in default).
1306+
const appDefaultProfile = appDefaultProfileName((config as any)?.permissions);
1307+
await kernel.use(new SecurityPlugin(appDefaultProfile ? { fallbackPermissionSet: appDefaultProfile } : undefined));
13021308
trackPlugin('Security');
13031309
} catch {
13041310
// optional
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// SHOWCASE proof for ADR-0056 D7 — the app-declared default profile, wired the
4+
// way the CLI wires it. The showcase declares `showcase_member_default` with
5+
// `isDefault: true`; `appDefaultProfileName(stack.permissions)` (the helper the
6+
// CLI calls) extracts its name, and passing it as the SecurityPlugin
7+
// `fallbackPermissionSet` makes a fresh sign-up governed by THAT profile instead
8+
// of the built-in `member_default` wildcard. Read-mostly default ⇒ the member
9+
// can read announcements but is DENIED the private-note object (which the
10+
// wildcard would have allowed) — proving the app's declared default is in force.
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, appDefaultProfileName } from '@objectstack/plugin-security';
16+
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
17+
18+
// Mirror the CLI: pull the app-declared default profile (name + object) off the
19+
// stack metadata via the same helper the CLI uses.
20+
const stackPerms = ((showcaseStack as { permissions?: unknown[] }).permissions ?? []) as Array<{ name?: string }>;
21+
const appDefault = appDefaultProfileName(stackPerms);
22+
const declaredDefault = stackPerms.find((p) => p?.name === appDefault) as unknown;
23+
24+
describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () => {
25+
let stack: VerifyStack;
26+
let memberToken: string;
27+
28+
beforeAll(async () => {
29+
// The full CLI boot loads stack permission sets into the metadata service, so
30+
// `fallbackPermissionSet: <name>` resolves there. The lightweight harness does
31+
// not seed permission metadata, so we hand the declared default to the plugin
32+
// directly — then wire it by NAME exactly as the CLI's appDefaultProfileName
33+
// path does (constructor uses the explicit name, not its own isDefault scan).
34+
stack = await bootStack(showcaseStack, {
35+
security: new SecurityPlugin({
36+
defaultPermissionSets: [...securityDefaultPermissionSets, PermissionSetSchema.parse(declaredDefault) as PermissionSet],
37+
fallbackPermissionSet: appDefault,
38+
}),
39+
});
40+
await stack.signIn();
41+
memberToken = await stack.signUp('d7-showcase-member@verify.test');
42+
}, 60_000);
43+
44+
afterAll(async () => { await stack?.stop(); });
45+
46+
it('appDefaultProfileName extracts the showcase default profile from stack metadata', () => {
47+
expect(appDefault).toBe('showcase_member_default');
48+
});
49+
50+
it('a fresh member is governed by the app-declared default (reads announcements)', async () => {
51+
const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_announcement');
52+
expect(r.status, 'declared default grants announcement read').toBe(200);
53+
});
54+
55+
it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => {
56+
const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note');
57+
// member_default has a wildcard grant → would be 200. The app default grants
58+
// no private_note access → denied, proving the declared default is in force.
59+
expect(r.status, 'declared default does NOT grant private_note').not.toBe(200);
60+
});
61+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect } from 'vitest';
3+
import { appDefaultProfileName } from './app-default-profile.js';
4+
5+
describe('appDefaultProfileName (ADR-0056 D7)', () => {
6+
it('returns the name of the first isProfile+isDefault permission set', () => {
7+
const perms = [
8+
{ name: 'add_on', isProfile: false, isDefault: true }, // not a profile → skipped
9+
{ name: 'member', isProfile: true }, // not default → skipped
10+
{ name: 'app_default', isProfile: true, isDefault: true },
11+
{ name: 'second_default', isProfile: true, isDefault: true },
12+
];
13+
expect(appDefaultProfileName(perms)).toBe('app_default');
14+
});
15+
16+
it('treats a profile with no explicit isProfile flag as a profile', () => {
17+
expect(appDefaultProfileName([{ name: 'd', isDefault: true }])).toBe('d');
18+
});
19+
20+
it('returns undefined when no default profile is declared', () => {
21+
expect(appDefaultProfileName([{ name: 'a', isProfile: true }])).toBeUndefined();
22+
expect(appDefaultProfileName([])).toBeUndefined();
23+
expect(appDefaultProfileName(undefined)).toBeUndefined();
24+
expect(appDefaultProfileName(null)).toBeUndefined();
25+
expect(appDefaultProfileName('nope')).toBeUndefined();
26+
});
27+
28+
it('ignores a default flag on a non-profile add-on permission set', () => {
29+
expect(appDefaultProfileName([{ name: 'addon', isProfile: false, isDefault: true }])).toBeUndefined();
30+
});
31+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0056 D7 — resolve the app-declared default profile NAME from a stack's
5+
* `permissions[]` array.
6+
*
7+
* A permission set marked `isProfile && isDefault` declares the app's default
8+
* access posture for users with no explicit grants. The {@link SecurityPlugin}
9+
* constructor scans its `defaultPermissionSets` option for that flag — but the
10+
* CLI constructs `new SecurityPlugin()` with NO options, so an `isDefault`
11+
* profile declared purely in app METADATA would never be honored. The CLI calls
12+
* this helper to pull the name out of the stack and pass it as
13+
* `fallbackPermissionSet`, wiring the declaration through to `pnpm dev`.
14+
*
15+
* Returns the first matching profile's `name`, or `undefined` when none is
16+
* declared (callers then keep the built-in `member_default` fallback).
17+
*/
18+
export function appDefaultProfileName(permissions: unknown): string | undefined {
19+
if (!Array.isArray(permissions)) return undefined;
20+
for (const p of permissions) {
21+
if (p && typeof p === 'object') {
22+
const ps = p as { name?: unknown; isProfile?: unknown; isDefault?: unknown };
23+
// `isProfile !== false` mirrors the stack convention where profiles double
24+
// as the user's baseline; permission-set add-ons set `isProfile: false`.
25+
if (
26+
ps.isDefault === true &&
27+
ps.isProfile !== false &&
28+
typeof ps.name === 'string' &&
29+
ps.name.length > 0
30+
) {
31+
return ps.name;
32+
}
33+
}
34+
}
35+
return undefined;
36+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export {
2525
} from './auto-org-admin-grant.js';
2626
export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
2727
export { claimSeedOwnership } from './claim-seed-ownership.js';
28+
export { appDefaultProfileName } from './app-default-profile.js';

0 commit comments

Comments
 (0)