Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/33132-platform-baseline-host-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/plugin-security': patch
---

feat(plugin-security): a host can keep a platform baseline contribution at read, or decline it, through `platformBaselineOverrides`

Clause-②: yes (widening) — `SecurityPlugin` accepts a new, opt-in option, `platformBaselineOverrides`, and the package exports two new types, `PlatformBaselineOverride` and `PlatformBaselineOverrides`. Omitted, every platform baseline contribution is admitted as contributed, as before.
Metadata-protocol: none

`SecurityPlugin` takes `platformBaselineOverrides`, keyed by contributed permission set name and object: `'read'` keeps the object's read and withholds its create, `'decline'` withholds the object. For example, a host on which members must not create attachments declares `{ storage_attachment_member: { sys_attachment: 'read' } }`. The override is applied inside the platform baseline seam, where the contribution is admitted, so the data plane, `security/explain`, the effective-access read and the `everyone` anchor all read the narrowed set. It only takes bits away. A set left granting nothing joins no baseline. An override that no contribution matched by the end of boot is logged at `warn`, naming it. A malformed declaration is refused when the plugin is constructed. Before, a host could only get this by wrapping the security service's `contributePlatformBaselineSet`. (#33132)
18 changes: 18 additions & 0 deletions content/docs/permissions/attachments-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ The set reaches no one else:
auth service is composed for the download check; then it contributes no set
either, and the boot log names the missing gate and what to add.

A host can also narrow the set for its own deployment, through the
`SecurityPlugin` option `platformBaselineOverrides`. It names a contributed set
and one of its objects, and either keeps that object at read (`'read'`) or
withholds it (`'decline'`). A host on which members must see attachments but
never create them declares:

```ts
new SecurityPlugin({
platformBaselineOverrides: { storage_attachment_member: { sys_attachment: 'read' } },
});
```

Members then keep the read, and a create is refused `PERMISSION_DENIED` (403);
`security/explain` and the effective-access read show the narrowed set. An
override only takes bits away. A set left granting nothing joins no baseline,
and an override that matched no contributed set by the end of boot is logged
at `warn` naming it, so a renamed set or object does not pass unnoticed.

The set carries **no edit and no delete** (ADR-0090 D5: a set every member holds
carries no delete bit). Editing or deleting attachments still needs an explicit
grant on `sys_attachment` (see Delete below). The one removal a member makes
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/plugin-security/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
*/

export { SecurityPlugin } from './security-plugin.js';
// [#33132] The shape of `SecurityPluginOptions.platformBaselineOverrides`, for a host that declares one.
export type { PlatformBaselineOverride, PlatformBaselineOverrides } from './platform-baseline-sets.js';
export { PermissionEvaluator } from './permission-evaluator.js';
export { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
export { FieldMasker, maskFieldValue, MASK_CHAR } from './field-masker.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
* `everyone` definition names it; an external member and an agent principal
* gain nothing; a contribution naming another package's object is refused on
* the real registry; the window is closed once the kernel has bootstrapped;
* and a deployment declaring no member baseline hands the set to no one.
* a deployment declaring no member baseline hands the set to no one; and a
* host's `platformBaselineOverrides` (#33132) keeps the set at read, so a
* member's create is refused while the read still serves.
* Each refusal rule on its own: `platform-baseline-sets.test.ts`.
*/

Expand All @@ -34,6 +36,7 @@ import type { ISecurityService, PlatformBaselineSetContribution } from '@objects
import { mapDataError } from '@objectstack/types';
import { SecurityPlugin } from './security-plugin.js';
import { defaultPermissionSets } from './objects/default-permission-sets.js';
import type { PlatformBaselineOverrides } from './platform-baseline-sets.js';

/** The platform service's package id, and the object it registers. */
/** The members this suite drives, each one the plugin serves. */
Expand Down Expand Up @@ -137,7 +140,12 @@ function servicePlugin(outcomes: Record<string, Outcome>): Plugin {
};
}

async function boot(fallbackPermissionSet: string | null, outcomes: Record<string, Outcome>, logs?: BootLogs): Promise<ObjectKernel> {
async function boot(
fallbackPermissionSet: string | null,
outcomes: Record<string, Outcome>,
logs?: BootLogs,
platformBaselineOverrides?: PlatformBaselineOverrides,
): Promise<ObjectKernel> {
const kernel = new ObjectKernel({ logger: { level: 'silent' } });
// Every plugin context logs through the kernel's one logger: the calls are
// the record, whatever level the logger then prints at.
Expand All @@ -149,7 +157,9 @@ async function boot(fallbackPermissionSet: string | null, outcomes: Record<strin
await kernel.use(sqliteDriverPlugin());
await kernel.use(new ObjectQLPlugin());
await kernel.use(appPlugin());
await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...defaultPermissionSets, APP_SET], fallbackPermissionSet }));
await kernel.use(
new SecurityPlugin({ defaultPermissionSets: [...defaultPermissionSets, APP_SET], fallbackPermissionSet, platformBaselineOverrides }),
);
await kernel.use(servicePlugin(outcomes));
await kernel.bootstrap();
return kernel;
Expand Down Expand Up @@ -322,3 +332,65 @@ describe('a deployment that declares no member baseline (fallbackPermissionSet:
expect(admitted[0][1]).toEqual({ contributor: SERVICE, set: SERVICE_SET.name, joined: false });
});
});

describe('#33132 — a host keeps a contributed set at read through platformBaselineOverrides', () => {
let kernel: ObjectKernel;
let ql: ObjectQL;
let security: SecurityService;
const outcomes: Record<string, Outcome> = {};
const logs: BootLogs = { info: [], warn: [] };
let alice: Ctx;

beforeAll(async () => {
kernel = await boot(APP_SET.name, outcomes, logs, {
[SERVICE_SET.name]: { [NOTE]: 'read' },
// Names a set no service contributes: it withholds nothing and is said.
pbs_renamed_member: { [NOTE]: 'decline' },
});
ql = kernel.getService<ObjectQL>('objectql');
security = kernel.getService<SecurityService>('security');
await seedPeople(ql);
alice = await contextOf(ql, ALICE);
}, 120_000);

afterAll(async () => {
if (kernel) await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 10_000))]);
}, 30_000);

it('the set still joins the baseline, and a member still reads the object', async () => {
expect(outcomes.own).toEqual({ joined: true });
const rows = (await ql.find(NOTE, { context: alice } as never)) as Array<Record<string, unknown>>;
expect(rows.map((r) => r.name)).toContain('seeded note');
});

it('a member\'s create is refused 403, and the explainer says the create is not allowed', async () => {
expect(await refusal(() => ql.insert(NOTE, { name: 'alice wrote this' }, { context: alice } as never), NOTE))
.toMatchObject({ status: 403 });
const decision = await security.explain({ object: NOTE, operation: 'create' }, alice);
expect(decision.allowed).toBe(false);
});

it('the catalog holds the narrowed body: read on, create off', () => {
const item = (ql as any).registry.getItem('permission', SERVICE_SET.name);
expect(item?.objects?.[NOTE]).toMatchObject({ allowRead: true, allowCreate: false });
});

it('the admission line says what the host narrowed', () => {
const admitted = logs.info.filter(([m]) => String(m).startsWith(`[security] platform baseline set '${SERVICE_SET.name}'`));
expect(admitted).toHaveLength(1);
expect(admitted[0][0]).toBe(
`[security] platform baseline set '${SERVICE_SET.name}' from '${SERVICE}': ${NOTE} (read) — ` +
'joined the member baseline every signed-in member holds through the everyone anchor.' +
` The host's platformBaselineOverrides narrowed it: ${NOTE}: kept at read.`,
);
expect(admitted[0][1]).toEqual({ contributor: SERVICE, set: SERVICE_SET.name, joined: true, overridden: [`${NOTE}: kept at read`] });
});

it('an override no contribution matched is warned once the kernel has bootstrapped', () => {
const unmatched = logs.warn.filter(([m]) => String(m).includes('[security] platformBaselineOverrides names'));
expect(unmatched).toHaveLength(1);
expect(unmatched[0][0]).toContain(`'pbs_renamed_member.${NOTE}'`);
expect(unmatched[0][0]).toContain('withheld nothing');
expect(unmatched[0][1]).toEqual({ override: `pbs_renamed_member.${NOTE}` });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
import { describe, it, expect } from 'vitest';
import { PermissionSetSchema } from '@objectstack/spec/security';

import { PlatformBaselineSets, type PlatformBaselineHost, type PlatformBaselineRegistry } from './platform-baseline-sets.js';
import {
PlatformBaselineSets,
readPlatformBaselineOverridesOption,
type PlatformBaselineHost,
type PlatformBaselineRegistry,
} from './platform-baseline-sets.js';

const CONTRIBUTOR = 'com.example.service.notes';
const OWN = 'pbs_note';
Expand Down Expand Up @@ -232,3 +237,93 @@ describe('a refused contribution changes nothing', () => {
expect(message).toMatch(/the registry refused the set: permission 'pbs_notes_member' is held/);
});
});

describe('#33132 — the host\'s overrides narrow an admitted contribution, and never widen one', () => {
const TWO = Object.freeze({
name: SET.name,
objects: { [OWN]: { allowRead: true, allowCreate: true }, pbs_draft: { allowRead: true, allowCreate: true } },
});

it("'read' keeps the object's read and withholds its create; the narrowed body is what is registered", () => {
const r = rig();
const seams = new PlatformBaselineSets(readPlatformBaselineOverridesOption({ [SET.name]: { [OWN]: 'read' } }));
const admitted = seams.contribute(CONTRIBUTOR, SET, r.host);
expect(admitted).toMatchObject({ joined: true, overridden: [`${OWN}: kept at read`] });
expect(admitted.set.objects?.[OWN]).toMatchObject({ allowRead: true, allowCreate: false });
expect(r.registered).toHaveLength(1);
expect((r.registered[0].item.objects as any)[OWN]).toMatchObject({ allowRead: true, allowCreate: false });
expect(r.host.baseline).toContain(SET.name);
expect(seams.body(SET.name)?.objects?.[OWN]).toMatchObject({ allowCreate: false });
});

it("'decline' on one of two objects withholds that object and keeps the other as contributed", () => {
const r = rig();
const seams = new PlatformBaselineSets(readPlatformBaselineOverridesOption({ [SET.name]: { pbs_draft: 'decline' } }));
const admitted = seams.contribute(CONTRIBUTOR, TWO, r.host);
expect(admitted).toMatchObject({ joined: true, overridden: ['pbs_draft: declined'] });
expect(Object.keys(admitted.set.objects ?? {})).toEqual([OWN]);
expect(admitted.set.objects?.[OWN]).toMatchObject({ allowRead: true, allowCreate: true });
});

it('a set the overrides leave granting nothing is not registered and joins no baseline', () => {
const r = rig();
const before = [...r.host.baseline];
const seams = new PlatformBaselineSets(readPlatformBaselineOverridesOption({ [SET.name]: { [OWN]: 'decline' } }));
const admitted = seams.contribute(CONTRIBUTOR, SET, r.host);
expect(admitted).toMatchObject({ joined: false, overridden: [`${OWN}: declined`] });
expect(r.registered).toEqual([]);
expect(r.host.baseline).toEqual(before);
expect(seams.unmatchedOverrides()).toEqual([]);
});

it("'read' on an object the set grants create only withholds it: an override never adds read", () => {
const r = rig();
const createOnly = { name: SET.name, objects: { [OWN]: { allowCreate: true } } };
const seams = new PlatformBaselineSets(readPlatformBaselineOverridesOption({ [SET.name]: { [OWN]: 'read' } }));
const admitted = seams.contribute(CONTRIBUTOR, createOnly, r.host);
expect(admitted).toMatchObject({ joined: false, overridden: [`${OWN}: declined (the set grants it no read to keep)`] });
expect(r.registered).toEqual([]);
});

it('a contribution the seam refuses is refused with an override too, and the override stays unmatched', () => {
const seams = new PlatformBaselineSets(readPlatformBaselineOverridesOption({ [SET.name]: { [OWN]: 'read' } }));
const message = refusalOf({ ...SET, objects: { [OWN]: { allowRead: true, allowEdit: true } } }, { seams });
expect(message).toContain("carries 'allowEdit'");
expect(seams.unmatchedOverrides()).toEqual([`${SET.name}.${OWN}`]);
});

it('another set is admitted as contributed, and an override naming nothing admitted is reported unmatched', () => {
const r = rig();
const seams = new PlatformBaselineSets(
readPlatformBaselineOverridesOption({ pbs_other_member: { [OWN]: 'decline' }, [SET.name]: { pbs_gone: 'decline' } }),
);
const admitted = seams.contribute(CONTRIBUTOR, SET, r.host);
expect(admitted).toMatchObject({ joined: true, overridden: [] });
expect(admitted.set.objects?.[OWN]).toMatchObject({ allowRead: true, allowCreate: true });
expect(seams.unmatchedOverrides()).toEqual([`pbs_other_member.${OWN}`, `${SET.name}.pbs_gone`]);
});

it('no override declared: the admitted set is the contribution, with nothing overridden', () => {
const r = rig();
const admitted = new PlatformBaselineSets().contribute(CONTRIBUTOR, SET, r.host);
expect(admitted.overridden).toEqual([]);
expect(admitted.set.objects?.[OWN]).toMatchObject({ allowRead: true, allowCreate: true });
});
});

describe('#33132 — readPlatformBaselineOverridesOption refuses a malformed declaration', () => {
it('reads undefined as no override', () => {
expect(readPlatformBaselineOverridesOption(undefined)).toEqual({});
});

it.each([
['a non-object', ['x'], 'not an object keyed by contributed permission set name'],
['an empty set name', { '': { [OWN]: 'read' } }, 'a set name is empty'],
['a set entry naming no object', { [SET.name]: {} }, `the entry for '${SET.name}' names no object`],
["object '*'", { [SET.name]: { '*': 'decline' } }, "names object '*'"],
['an unknown verdict', { [SET.name]: { [OWN]: 'none' } }, `'${SET.name}.${OWN}' is "none"`],
])('refuses %s', (_label, raw, fragment) => {
expect(() => readPlatformBaselineOverridesOption(raw)).toThrow(fragment);
expect(() => readPlatformBaselineOverridesOption(raw)).toThrow('[security] SecurityPlugin option platformBaselineOverrides refused');
});
});
Loading
Loading