Skip to content

Commit 90462a7

Browse files
committed
test(plugin-security): pin the walled-posture accepted-defaults declaration
1 parent 3c2fa8f commit 90462a7

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

‎packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
dismissAudienceBindingSuggestion,
1414
SuggestionNotFoundError,
1515
SuggestionStateError,
16+
readAcceptedDefaultPermissionSetsOption,
17+
refuseAcceptedDefaultsWithoutWall,
18+
type DeploymentAcceptance,
1619
type SuggestionDeps,
1720
} from './suggested-audience-bindings';
1821
import { declareEveryoneBaseline } from './builtin-positions';
@@ -763,3 +766,175 @@ describe('[ADR-0086 D3] a set\'s owner is its binding — a body `packageId` on
763766
]);
764767
});
765768
});
769+
770+
// ── [ADR-0131 D5] Under a wall, the deployment-level acceptance may be host
771+
// configuration — `acceptedDefaultPermissionSets` on the security plugin's
772+
// options. No organization owns anything for it (ADR-0131 §1.5), and the
773+
// `single` posture's record is untouched: every block above runs with no
774+
// `DeploymentAcceptance`, which is read as that record.
775+
describe('[ADR-0131 D5] the walled posture: deployment-level acceptance is host configuration', () => {
776+
const OPTION = 'acceptedDefaultPermissionSets';
777+
/** Two organizations, neither with slug `default`: no record owner (a walled multi-organization deployment). */
778+
const noRecordOwner = (ql: any) => {
779+
ql.tables.sys_organization = [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }];
780+
return ql;
781+
};
782+
const walled = (declared?: readonly string[]): DeploymentAcceptance => ({
783+
posture: 'isolated',
784+
...(declared !== undefined ? { acceptedDefaultPermissionSets: declared } : {}),
785+
});
786+
/** The security plugin's composition under a wall: baseline + whatever the derivation accepts. */
787+
const walledDeps = (ql: any, declared?: readonly string[]): SuggestionDeps => ({
788+
ql,
789+
posture: () => 'isolated',
790+
acceptedDefaultPermissionSets: declared,
791+
rederiveEveryone: async () => {
792+
const { accepted } = await readAcceptedEveryoneSets(ql, undefined, walled(declared));
793+
declareEveryoneBaseline(ql.registry, SECURITY_PACKAGE, [...new Set([...BASELINE, ...accepted])]);
794+
},
795+
});
796+
const capture = () => {
797+
const warns: string[] = [];
798+
return { warns, logger: { warn: (m: string) => { warns.push(m); }, info: () => undefined } };
799+
};
800+
801+
it('a declared EMPTY list: the boot reconcile neither throws nor warns, everyone is the baseline, and the door refuses naming the option', async () => {
802+
const ql = noRecordOwner(makeQl([CRM_PACKAGE]));
803+
const { warns, logger } = capture();
804+
805+
// The two `(non-fatal)` warn sites in the security plugin log exactly when this rejects.
806+
await expect(reconcileAudienceBindingSuggestions(ql, logger, walled([]))).resolves.toMatchObject({ created: 0, unread: 0 });
807+
expect(warns).toEqual([]);
808+
expect(rows(ql)).toHaveLength(0);
809+
810+
const deps = walledDeps(ql, []);
811+
await deps.rederiveEveryone();
812+
expect(everyoneSets(ql)).toEqual(BASELINE);
813+
expect(await readAcceptedEveryoneSets(ql, undefined, walled([]))).toEqual({ accepted: [], withheld: [] });
814+
815+
for (const door of [
816+
confirmAudienceBindingSuggestion(deps, OPERATOR_CTX, 'sug_any'),
817+
dismissAudienceBindingSuggestion(deps, OPERATOR_CTX, 'sug_any'),
818+
listAudienceBindingSuggestions(deps, OPERATOR_CTX, {}),
819+
]) {
820+
const refusal = door.catch((e: unknown) => e);
821+
await expect(refusal).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 });
822+
const message = String(((await refusal) as Error).message);
823+
expect(message).toMatch(/^This deployment runs the 'isolated' tenancy posture and declares its accepted package defaults/);
824+
expect(message).toContain(`\`${OPTION}\``);
825+
expect(message).not.toContain('create the Default Organization');
826+
}
827+
// The deployment authority still answers first: a non-operator is a 403, not a 409.
828+
await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, 'sug_any'))
829+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
830+
});
831+
832+
it('a declared set is granted through everyone — and only through the declaration (anti-vacuity: absent before)', async () => {
833+
const ql = noRecordOwner(makeQl([CRM_PACKAGE]));
834+
expect(everyoneSets(ql)).not.toContain('crm_readonly');
835+
836+
const derived = await readAcceptedEveryoneSets(ql, undefined, walled(['crm_readonly']));
837+
expect(derived).toEqual({ accepted: ['crm_readonly'], withheld: [] });
838+
839+
await walledDeps(ql, ['crm_readonly']).rederiveEveryone();
840+
expect(everyoneSets(ql)).toEqual([...BASELINE, 'crm_readonly']);
841+
expect(ql.registry.getItem('position', 'everyone')._packageId).toBe(SECURITY_PACKAGE);
842+
expect(rows(ql)).toHaveLength(0);
843+
});
844+
845+
it('a declared name passes the everyone anchor\'s high-risk gate — a forbidden set is withheld as forbidden, never granted (ADR-0090 D7)', async () => {
846+
const evil = {
847+
enabled: true,
848+
manifest: {
849+
id: 'com.example.evil',
850+
permissions: [{ name: 'evil_default', isDefault: true, objects: { '*': { viewAllRecords: true } } }],
851+
},
852+
};
853+
const ql = noRecordOwner(makeQl([evil]));
854+
const derived = await readAcceptedEveryoneSets(ql, undefined, walled(['evil_default']));
855+
expect(derived.accepted).toEqual([]);
856+
expect(derived.withheld).toEqual([
857+
expect.objectContaining({ set: 'evil_default', forbidden: true, source: 'host-configuration', packageId: 'com.example.evil' }),
858+
]);
859+
await walledDeps(ql, ['evil_default']).rederiveEveryone();
860+
expect(everyoneSets(ql)).toEqual(BASELINE);
861+
});
862+
863+
it('a declared name the catalog does not serve as a package\'s isDefault set is withheld with its reason, as an accepted row is', async () => {
864+
const unowned = { enabled: true, manifest: { permissions: [{ name: 'loose_default', isDefault: true, objects: {} }] } };
865+
const ql = noRecordOwner(makeQl([CRM_PACKAGE, unowned]));
866+
const derived = await readAcceptedEveryoneSets(
867+
ql, undefined, walled(['no_such_set', 'crm_admin', 'loose_default', 'crm_readonly']),
868+
);
869+
expect(derived.accepted).toEqual(['crm_readonly']);
870+
expect(derived.withheld).toEqual([
871+
{ set: 'no_such_set', packageId: '', forbidden: false, source: 'host-configuration',
872+
reason: 'the security catalog serves no set of that name (package uninstalled or disabled)' },
873+
{ set: 'crm_admin', packageId: 'com.example.crm', forbidden: false, source: 'host-configuration',
874+
reason: 'the set no longer declares isDefault: true' },
875+
{ set: 'loose_default', packageId: '', forbidden: false, source: 'host-configuration',
876+
reason: "the set is declared by 'no package' now" },
877+
]);
878+
});
879+
880+
it('declared, the record is not read even where an organization could own it — one source, never two', async () => {
881+
const ql = makeQl([CRM_PACKAGE]); // a `default` organization exists
882+
const deps = makeDeps(ql);
883+
const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {});
884+
await confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id);
885+
expect((await readAcceptedEveryoneSets(ql)).accepted).toEqual(['crm_readonly']);
886+
887+
expect(await readAcceptedEveryoneSets(ql, undefined, walled([]))).toEqual({ accepted: [], withheld: [] });
888+
// The sync writes nothing; the rows it no longer reads are reported once, naming the option.
889+
const { warns, logger } = capture();
890+
const before = JSON.stringify(rows(ql));
891+
await expect(reconcileAudienceBindingSuggestions(ql, logger, walled([]))).resolves.toMatchObject({ created: 0, unread: 1 });
892+
expect(JSON.stringify(rows(ql))).toBe(before);
893+
expect(warns).toHaveLength(1);
894+
expect(warns[0]).toContain(`\`${OPTION}\``);
895+
});
896+
897+
it('a walled boot with NO declaration keeps today\'s behaviour, and the refusal names the option, not the Default Organization', async () => {
898+
const ql = noRecordOwner(makeQl([CRM_PACKAGE]));
899+
const sync = reconcileAudienceBindingSuggestions(ql, undefined, walled()).catch((e: unknown) => e);
900+
await expect(sync).resolves.toBeInstanceOf(SuggestionStateError);
901+
await expect(sync).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 });
902+
const message = String(((await sync) as Error).message);
903+
expect(message).toMatch(/^The audience-binding acceptance is deployment-level, and this deployment runs the 'isolated' tenancy posture with no organization that can own the record/);
904+
expect(message).toContain(`\`${OPTION}\``);
905+
expect(message).toContain('ADR-0131 §1.5');
906+
expect(message).not.toContain('create the Default Organization');
907+
908+
const door = confirmAudienceBindingSuggestion(walledDeps(ql), OPERATOR_CTX, 'sug_any').catch((e: unknown) => e);
909+
await expect(door).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 });
910+
expect(String(((await door) as Error).message)).toBe(message);
911+
expect(everyoneSets(ql)).toEqual(BASELINE);
912+
});
913+
914+
it('under `single` the refusal keeps D5\'s Default-Organization text, and the posture never reads a declaration', async () => {
915+
for (const deployment of [undefined, { posture: 'single' as const }, { posture: 'single' as const, acceptedDefaultPermissionSets: ['crm_readonly'] }]) {
916+
const ql = noRecordOwner(makeQl([CRM_PACKAGE]));
917+
const sync = syncAudienceBindingSuggestions(ql, undefined, deployment).catch((e: unknown) => e);
918+
await expect(sync).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 });
919+
const message = String(((await sync) as Error).message);
920+
expect(message).toMatch(/^The audience-binding acceptance is recorded once per deployment and owned by the Default Organization/);
921+
expect(message).toContain("Remedy: create the Default Organization (slug 'default')");
922+
expect((await readAcceptedEveryoneSets(ql, undefined, deployment)).accepted).toEqual([]);
923+
}
924+
});
925+
926+
it('the option is a closed list of names — anything else is refused at construction, and `single` refuses a declaration', () => {
927+
expect(readAcceptedDefaultPermissionSetsOption(undefined)).toBeUndefined();
928+
expect(readAcceptedDefaultPermissionSetsOption([])).toEqual([]);
929+
expect(readAcceptedDefaultPermissionSetsOption(['a', 'b', 'a'])).toEqual(['a', 'b']);
930+
for (const bad of [null, 'crm_readonly', [''], [' '], [1], {}]) {
931+
expect(() => readAcceptedDefaultPermissionSetsOption(bad)).toThrow(/`acceptedDefaultPermissionSets` must be a list of permission-set names/);
932+
}
933+
expect(() => refuseAcceptedDefaultsWithoutWall('single', [])).toThrow(
934+
/`acceptedDefaultPermissionSets` is declared, and this deployment runs the 'single' tenancy posture, which does not read it/,
935+
);
936+
expect(() => refuseAcceptedDefaultsWithoutWall('single', undefined)).not.toThrow();
937+
expect(() => refuseAcceptedDefaultsWithoutWall('isolated', [])).not.toThrow();
938+
expect(() => refuseAcceptedDefaultsWithoutWall('group', ['crm_readonly'])).not.toThrow();
939+
});
940+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
/**
3+
* [ADR-0131 D5] `SecurityPluginOptions.acceptedDefaultPermissionSets` — the
4+
* walled posture's host-declared deployment-level acceptance — is read only
5+
* under a wall, and refused loudly everywhere it would not be read: a
6+
* malformed value at construction, a declaration under `single` at `start()`,
7+
* the first moment the posture is known. The derivation and the doors that
8+
* read it are pinned in `suggested-audience-bindings.test.ts`.
9+
*/
10+
11+
import { afterEach, describe, expect, it } from 'vitest';
12+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
13+
import { ObjectQLPlugin } from '@objectstack/objectql';
14+
import { SqlDriver } from '@objectstack/driver-sql';
15+
import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity';
16+
import { SecurityPlugin } from './security-plugin.js';
17+
18+
/** Publishes an in-memory SQLite driver and the identity objects the authz store reads. */
19+
function basePlugins(): Plugin[] {
20+
return [
21+
{
22+
name: 'test.driver.sqlite',
23+
type: 'standard',
24+
version: '1.0.0',
25+
async init(ctx: PluginContext) {
26+
ctx.registerService(
27+
'driver.default',
28+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
29+
);
30+
},
31+
},
32+
new ObjectQLPlugin(),
33+
{
34+
name: 'test.identity-stand-in',
35+
type: 'standard',
36+
version: '1.0.0',
37+
dependencies: ['com.objectstack.engine.objectql'],
38+
async init(ctx: PluginContext) {
39+
ctx.getService<{ register(m: unknown): unknown }>('manifest').register({
40+
id: 'test.identity-stand-in',
41+
namespace: 'sys',
42+
version: '1.0.0',
43+
type: 'plugin',
44+
name: 'Identity stand-in',
45+
objects: [SysUser, SysMember, SysOrganization],
46+
});
47+
},
48+
},
49+
];
50+
}
51+
52+
/** The `tenancy` service a walled deployment's auth plugin registers (ADR-0105 D1). */
53+
function tenancyPlugin(posture: 'isolated'): Plugin {
54+
return {
55+
name: 'test.tenancy',
56+
type: 'standard',
57+
version: '1.0.0',
58+
async init(ctx: PluginContext) {
59+
ctx.registerService('tenancy', { posture });
60+
},
61+
};
62+
}
63+
64+
describe('[ADR-0131 D5] acceptedDefaultPermissionSets is read under a wall and refused where it is not', () => {
65+
let kernel: ObjectKernel | undefined;
66+
67+
afterEach(async () => {
68+
try {
69+
await kernel?.shutdown();
70+
} catch {
71+
/* a refused boot leaves the kernel stopped */
72+
}
73+
kernel = undefined;
74+
});
75+
76+
async function boot(security: SecurityPlugin, extra: Plugin[] = []): Promise<unknown> {
77+
kernel = new ObjectKernel({ logger: { level: 'silent' } });
78+
for (const p of [...basePlugins(), ...extra]) await kernel.use(p);
79+
await kernel.use(security);
80+
try {
81+
await kernel.bootstrap();
82+
return undefined;
83+
} catch (err) {
84+
return err;
85+
}
86+
}
87+
88+
it('refuses a malformed declaration at construction', () => {
89+
expect(() => new SecurityPlugin({ acceptedDefaultPermissionSets: 'crm_readonly' as never })).toThrow(
90+
/`acceptedDefaultPermissionSets` must be a list of permission-set names/,
91+
);
92+
expect(() => new SecurityPlugin({ acceptedDefaultPermissionSets: [] })).not.toThrow();
93+
});
94+
95+
it('refuses the boot when the `single` posture would ignore the declaration', async () => {
96+
const err = await boot(new SecurityPlugin({ acceptedDefaultPermissionSets: [] }));
97+
98+
expect(err).toBeInstanceOf(Error);
99+
expect((err as Error).message).toMatch(
100+
/failed to start - rollback complete: \[security\] the SecurityPlugin option `acceptedDefaultPermissionSets` is declared, and this deployment runs the 'single' tenancy posture/,
101+
);
102+
});
103+
104+
it('CONTROL: `single` without the declaration, and a wall with it, both boot', async () => {
105+
expect(await boot(new SecurityPlugin())).toBeUndefined();
106+
expect(kernel!.getState()).toBe('running');
107+
await kernel!.shutdown();
108+
109+
expect(await boot(new SecurityPlugin({ acceptedDefaultPermissionSets: [] }), [tenancyPlugin('isolated')])).toBeUndefined();
110+
expect(kernel!.getState()).toBe('running');
111+
});
112+
});

0 commit comments

Comments
 (0)