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
17 changes: 17 additions & 0 deletions .changeset/30108-member-default-feed-grants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): every member reads and posts comments, and reads the activity history, of the records they can read — the platform baseline `member_default` now grants `sys_comment` (read, create, edit) and `sys_activity` (read)

Clause-②: no — runtime permission behaviour (human-floor security/permission class, ruled by the maintainer), not a published accept-set or public-surface widening
Metadata-protocol: none

Comments (`enable.feeds`) and the activity timeline (`enable.activities`) are on by default on every object, but no shipped permission set named `sys_comment` or `sys_activity`. A member of an app whose permission sets name only the app's own objects — what `create-objectstack` generates — was refused both (`403` on `GET /api/v1/data/sys_comment` and `GET /api/v1/data/sys_activity`), on records they can read: the record page showed "no permission" for comments and activity, and offered no comment box. An app author no longer needs to grant the two objects by hand.

- **Which rows is unchanged.** The grant opens the two objects; plugin-audit's parent-record gates still decide the rows, so a member reads and posts comments, and reads the history, of exactly the records they can read, and nothing of any other record — a record another user owns under the app's row scope, or a record of an object the member does not read. A member edits the comments they wrote. The grant carries no delete bit (deleting a comment stays the moderation path, through a delete bit an app grants), no export and no view-all or modify-all.
- **`enable.feeds: false` still closes comments** on that object: creating a comment there, or moving one into it, answers `403 FEEDS_DISABLED`. `enable.activities: false` still writes no activity row for the object.
- **External members are not reached.** A holder of the `external` organization role does not hold the `everyone` anchor and resolves `external_default` in place of `member_default`, so a portal user still gets comments and activity only through an explicit grant, under the same two gates.
- **No log noise.** A member's activity read skips the parent objects they cannot read without reading them, so the console header's feed logs no `[Security] Access denied` line per skipped object.
- An app that already grants the two objects to its members keeps working unchanged: permission sets merge most-permissively, so its own lines (a delete bit included) still apply beside the baseline's.
- **Lands with the permission-set clone door fix** (`.changeset/30255-clone-inherits-held-grants.md`). This grant first landed alone and was reverted: on a deployment that does not install `@objectstack/plugin-audit`, neither object is registered, and cloning `member_default` answered `422 INVALID_METADATA`. The clone door now copies such a set, so the grant and the fix ship together.
13 changes: 13 additions & 0 deletions .changeset/30255-clone-inherits-held-grants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/runtime': patch
---

fix(runtime, metadata-protocol): the permission-set clone door copies a platform set whose grants name an object this deployment does not install — `member_default` copies into its package's customization pair again on a boot without `@objectstack/plugin-audit`

Clause-②: yes (widening) — `ObjectStackProtocolImplementation.saveMetaItem`'s request gains one optional key, `inheritedGrantObjects`, reachable from `@objectstack/metadata-protocol`'s `.` entry types
Metadata-protocol: none

- **What failed:** cloning a set through `POST /api/v1/security/permission-sets/:name/clone` answered `422 INVALID_METADATA` when the source grants on an object no package registers here. The commonest source is the platform baseline `member_default`, which grants `sys_comment` and `sys_activity`. `@objectstack/plugin-audit` registers both objects, and it is an optional package: a deployment that does not install it could not clone `member_default` at all.
- **The rule it now follows:** the save door's unknown-grant refusal (#28990) already leaves alone a grant the set itself already holds, because that grant is not the current write's. A clone's grants are the source's, copied verbatim, so the same holds for a copy. The clone door now tells the save which grant keys the copy inherits (`inheritedGrantObjects`), and the refusal does not judge those.
- **Still refused:** an unknown object that the save does not say it inherits is this write's grant, and is refused exactly as before. That covers a `/meta` save, a re-save that adds a grant, and a draft's publish. No HTTP door forwards the new key.
12 changes: 11 additions & 1 deletion packages/metadata-protocol/src/protocol-door-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ import type { MetadataWriteFace } from './protocol-write-face.js';
*/

/** The request of {@link ObjectStackProtocolImplementation.saveMetaItem}. */
type SaveMetaItemRequest = { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface, client?: MetadataAuthoringClient };
type SaveMetaItemRequest = {
type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface, client?: MetadataAuthoringClient,
/**
* [#30255] The grant keys of a `permission` item that this save copies
* verbatim from a set that already holds them — the permission-set clone
* door's copy. Not this write's grants, so the unknown-grant refusal does
* not judge them, exactly as it does not judge a re-save's grants the set
* already holds. Set by server code only; no HTTP door forwards it.
*/
inheritedGrantObjects?: readonly string[],
};

/** The request of {@link ObjectStackProtocolImplementation.publishMetaItem}. */
type PublishMetaItemRequest = {
Expand Down
9 changes: 9 additions & 0 deletions packages/metadata-protocol/src/protocol-save-door-refusals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ export async function unknownGrantObjectSaveRefusal(
pending?: 'drafts' | ReadonlySet<string>,
writeFace?: MetadataWriteFace,
door: 'save' | 'publish' = 'save',
inherited?: readonly string[],
): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
if (singularType !== 'permission') return undefined;
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
Expand Down Expand Up @@ -625,6 +626,14 @@ export async function unknownGrantObjectSaveRefusal(
: new Set<string>();
unknown = unknown.filter((key) => !held.has(key));
if (unknown.length === 0) return undefined;
// [#30255] A grant this save copies from a set that already holds it (the
// clone door's copy) is the source's, not this save's — the rule above,
// for a copy: judged where the source introduced it.
if (inherited && inherited.length > 0) {
const copied = new Set(inherited);
unknown = unknown.filter((key) => !copied.has(key));
if (unknown.length === 0) return undefined;
}
// [#29279] What was looked for, as this door looked: a save also counts an
// object saved here; a draft's publish counts only what goes live with it.
const looked = door === 'publish'
Expand Down
5 changes: 4 additions & 1 deletion packages/metadata-protocol/src/protocol-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1575,10 +1575,13 @@ export class ProtocolSaveFacet {
// already holds and is not judged: a draft-only set has no active row
// to hold its grants, so judging it refused the pass's own rewrite of
// a draft whose object draft was gone; the draft is judged at its
// publish. See {@link unknownGrantObjectSaveRefusal}.
// publish. [#30255] Grants the item copies from a set that already
// holds them (`inheritedGrantObjects`, the clone door's copy) are not
// this save's either. See {@link unknownGrantObjectSaveRefusal}.
if (writeSource !== 'migrate-stored') {
const grantRefusal = await host.unknownGrantObjectSaveRefusal(
singularType, request.item, request.name, mode === 'draft' ? 'drafts' : undefined, request.writeFace,
undefined, request.inheritedGrantObjects,
);
if (grantRefusal) throw grantRefusal;
}
Expand Down
14 changes: 12 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10302,7 +10302,16 @@ export class ObjectStackProtocolImplementation implements
* a set), since the batch promotes as one unit in no type order;
* - the set's current definition already grants on it: the grant is not
* this write's. A re-save or a restore that keeps it is not judged, and
* neither is an object that has gone since the grant was made.
* neither is an object that has gone since the grant was made;
* - [#30255] the save copies it verbatim from a set that already holds it
* (`inherited`, the request's `inheritedGrantObjects`: the permission-set
* clone door's copy). The grant is the source's, not this write's — the
* rule above, for a copy. A platform set may grant on an object of an
* optional platform plugin this deployment does not install
* (`member_default` on `sys_comment` / `sys_activity`, which
* `@objectstack/plugin-audit` registers): the grant applies to nothing
* here, in the source and in the copy alike, and the copy is what the
* clone door promises.
*
* Asked in draft and in publish mode by {@link saveMetaItem} after the
* schema gate (except a `migrate-stored` re-save, the platform healing
Expand All @@ -10323,14 +10332,15 @@ export class ObjectStackProtocolImplementation implements
pending?: 'drafts' | ReadonlySet<string>,
writeFace?: MetadataWriteFace,
door?: 'save' | 'publish',
inherited?: readonly string[],
): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
const readEngine = () => this.engine;
const host: ThisParameterType<typeof unknownGrantObjectSaveRefusal> = {
get engine() { return readEngine(); },
listDrafts: (...a) => this.listDrafts(...a),
readMetaItem: (...a) => this.readMetaItem(...a),
};
return unknownGrantObjectSaveRefusal.call(host, singularType, item, name, pending, writeFace, door);
return unknownGrantObjectSaveRefusal.call(host, singularType, item, name, pending, writeFace, door, inherited);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
* - controls, each saved, published and promoted: a registered object, a
* platform object (an RBAC link table), the `'*'` wildcard, an object
* stored in this environment, an object drafted in the same package and
* published in the same batch (promoted after the set), and a re-save
* that keeps a grant the set already holds; a registry that cannot look
* an object up refuses nothing.
* published in the same batch (promoted after the set), a re-save
* that keeps a grant the set already holds, and [#30255] a copy whose
* grants a set already holds (`inheritedGrantObjects`, the permission-set
* clone door's copy); a registry that cannot look an object up refuses
* nothing.
*
* Each refusal is asserted by its code, its status, its path and the first
* sentence of its finding, spelled out here.
Expand Down Expand Up @@ -363,6 +365,25 @@ describe('[#28990] controls — a grant on an object that exists is not refused'
expectRefusal(err, 'qa_old', ['qa_typo']);
});

it('[#30255] a copy that inherits a grant on an object not registered here (the clone door): saved; a grant the copy adds is still refused', async () => {
const { protocol, rows } = makeSession({ objects: ['qa_widget'] });

const result = await protocol.saveMetaItem({
type: 'permission', name: 'qa_copy', item: set('qa_copy', ['qa_widget', 'qa_plugin_object']),
inheritedGrantObjects: ['qa_widget', 'qa_plugin_object'],
});

expect(result.success).toBe(true);
expect(stored(rows, 'permission', 'qa_copy')).toEqual(['active|-|qa_widget,qa_plugin_object']);
// …while an unknown object the copy does not inherit is this save's grant, and refused.
const err = await refusalOf(protocol.saveMetaItem({
type: 'permission', name: 'qa_copy2', item: set('qa_copy2', ['qa_plugin_object', 'qa_typo']),
inheritedGrantObjects: ['qa_plugin_object'],
}));
expectRefusal(err, 'qa_copy2', ['qa_typo']);
expect(stored(rows, 'permission', 'qa_copy2')).toEqual([]);
});

it('a registry that cannot look an object up refuses nothing', async () => {
const { protocol, rows } = makeSession({ looksUpObjects: false });
expect((await protocol.saveMetaItem({ type: 'permission', name: 'qa_probe', item: set('qa_probe', ['qa_no_such_object']) })).success).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
* ## Who grants the bits
*
* The object-level create / read / delete bits come from an ordinary
* position-distributed set an application ships (as an app grants
* `sys_comment` today), never from the `everyone`-bound baseline, which
* position-distributed set an application ships (as an app grants the
* `sys_comment` delete bit; the baseline grants comments' read, create and
* edit, never delete), never from the `everyone`-bound baseline, which
* ADR-0090 D5 keeps free of delete bits. Which shipped set — if any — carries
* them is an open decision on #22566; the floor pinned here holds whatever set
* hands the delete bit out.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security';
import type { PermissionSet } from '@objectstack/spec/security';
import { PermissionEvaluator } from './permission-evaluator.js';
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './objects/default-permission-sets.js';
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS, EXTERNAL_DEFAULT_PERMISSION_SET } from './objects/default-permission-sets.js';

const evaluator = new PermissionEvaluator();
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
Expand Down Expand Up @@ -267,13 +267,10 @@ describe('[#7344] the personal inbox is readable by a member, scoped to their ow
expect(rls.compileFilter(policiesFor(object, 'select') as any, {} as any)).toEqual(RLS_DENY_FILTER);
});

it('`sys_activity` is deliberately NOT granted — the ruling excludes it', () => {
// Named as an explicit negative so a future "while we are here" sweep has to
// argue with the ruling rather than quietly widen past it. It is not a
// per-user-scoped shape; a separate question if it ever matters.
for (const { operation } of AXES) {
expect(allows(operation, [MEMBER_DEFAULT], 'sys_activity'), `sys_activity ${operation}`).toBe(false);
}
it('`sys_activity` rides on its own ruling, not on this one: no `_self` policy scopes it to the caller', () => {
// The inbox ruling left it out because it has no `user_id` to scope by. Its
// grant (the `[#30108]` block below) is scoped by its parent-record gate, so
// no inbox-shaped `_self` carve-out may appear for it.
const names = (MEMBER_DEFAULT.rowLevelSecurity ?? []).map((p: any) => p.name);
expect(names).not.toContain('sys_activity_self');
});
Expand All @@ -291,6 +288,60 @@ describe('[#7344] the personal inbox is readable by a member, scoped to their ow
});
});

// [#30108 — maintainer ruling 6633054352, 「同意 A」] A record's comments and its
// activity timeline, for every member who can read the record. The baseline
// opens the two OBJECTS and nothing more; which rows is plugin-audit's
// parent-record gates' answer, driven on a real stack in
// `member-default-feed-grants.integration.test.ts`. What is pinned here is the
// shape of the opening: the part that would quietly widen if it moved.
const FEED_OBJECTS = ['sys_comment', 'sys_activity'] as const;

describe('[#30108] the baseline opens comments and activity, and leaves the rows to the parent gates', () => {
it('sys_comment: a member reads, posts and edits — and holds no delete bit', () => {
expect(allows('find', [MEMBER_DEFAULT], 'sys_comment'), 'read').toBe(true);
expect(allows('insert', [MEMBER_DEFAULT], 'sys_comment'), 'create').toBe(true);
expect(allows('update', [MEMBER_DEFAULT], 'sys_comment'), 'edit').toBe(true);
expect(allows('delete', [MEMBER_DEFAULT], 'sys_comment'), 'delete').toBe(false);
});

it('sys_activity: a member reads — the audit mirror is its only writer', () => {
expect(allows('find', [MEMBER_DEFAULT], 'sys_activity'), 'read').toBe(true);
for (const operation of ['insert', 'update', 'delete']) {
expect(allows(operation, [MEMBER_DEFAULT], 'sys_activity'), operation).toBe(false);
}
});

it.each(FEED_OBJECTS)('%s: no read or create row policy of the baseline stands in for the parent gate', (object) => {
// A select- or insert-class policy here would answer the gate's question with
// a column predicate neither object can answer (no owner or user column, and
// a different parent object on every row).
expect(policiesFor(object, 'select').map((p: any) => p.name), 'select').toEqual([]);
expect(policiesFor(object, 'insert').map((p: any) => p.name), 'insert').toEqual([]);
});

it('sys_comment: an edit stays under the ownership floor — a member edits the comments they wrote', () => {
// The moderation carve-out is the DELETE limb only; the edit limb is the
// wildcard floor, so a member's edit reaches their own comments alone.
expect(policiesFor('sys_comment', 'update').map((p: any) => p.name)).toEqual(['owner_only_writes']);
});

it.each(FEED_OBJECTS)('%s: no export, view-all or modify-all rides along', (object) => {
const perm = (MEMBER_DEFAULT.objects as any)[object];
expect(perm, `${object} is named`).toBeTruthy();
expect(perm.allowDelete ?? false).toBe(false);
expect(perm.allowExport ?? false).toBe(false);
expect(perm.viewAllRecords ?? false).toBe(false);
expect(perm.modifyAllRecords ?? false).toBe(false);
});

it.each(FEED_OBJECTS)('%s: the external baseline does not name it — the grant reaches no external member', (object) => {
const external = defaultPermissionSets.find((p) => p.name === EXTERNAL_DEFAULT_PERMISSION_SET)!;
expect(external, `${EXTERNAL_DEFAULT_PERMISSION_SET} is shipped`).toBeTruthy();
expect(allows('find', [external], object)).toBe(false);
expect(Object.keys(external.objects ?? {})).not.toContain(object);
});
});

describe('[#5491] the admin sets keep their wildcards (this is a BASELINE change only)', () => {
it.each(['admin_full_access', 'organization_admin', 'viewer_readonly'])(
'%s still carries a `*` entry',
Expand Down
Loading
Loading