Skip to content

Commit 6dd2ca5

Browse files
fix(plugin-security, plugin-auth)!: a person-to-permission-set assignment refuses an agent ceiling set at every write door, and so does the self-registration permission set (#30578)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuLBXff21cMaYo16jV4jZerR)_ Clause-②: no (narrowing) — a non-system insert or update of `sys_user_permission_set` that would store an assignment naming one of the three MCP agent ceiling sets (`mcp_agent_data_read`, `mcp_agent_data_write`, `mcp_agent_restricted`) is refused with `400 VALIDATION_FAILED` (`invalid_value` at `permission_set`), for every caller, a platform administrator included; and an audience configuration whose `selfRegistrationPermissionSet` names one is refused (`[audience] invalid audience configuration`), at construction and through the auth settings; nothing is widened. Metadata-protocol: none Skills-docs: none — `skills/**` teaches neither the assignment door's sets nor the self-registration set. The `selfRegistrationPermissionSet` description in `packages/spec/src/system/auth-config.zod.ts` names the `admin_full_access` refusal, which stays true; it does not list every set the runtime refuses, and this change makes no sentence there false. ## 维护者速读 - **改了什么**:人员权限集分配表 `sys_user_permission_set` 的每个写入口(单条新建、更新、`createMany`、`updateMany`、两种批量、两种导入)现在拒绝存入三个 MCP 智能体上限权限集,统一返回 `400 VALIDATION_FAILED`,字段 `permission_set`、代码 `invalid_value`;受众配置的自助注册权限集 `selfRegistrationPermissionSet` 也拒绝这三个集(启动时与认证设置里同样拒绝)。 - **为什么改**:这三个权限集是经 MCP 接入的 AI 助手自己的权限上限,由其用户同意的范围在请求时推导,从不读分配行;分配表的每一行都是给人的授权,所以规则在服务端对每个写入口、每个调用者(平台管理员也不例外)成立;自助注册者也是人,所以给他们的权限集同样不能是智能体上限。 - **风险与回滚**:破坏性收窄,只影响曾经通过分配表给人分配这三个集、或把其中之一配置为自助注册权限集的部署:新写入和对此类旧行的编辑被拒,旧行原样保留、可删除或改名;这样的受众配置在启动时被拒,改成给人的权限集即可;系统写入不受本钩子评判;回滚即 revert 本 PR(一个新钩子模块、一行注册、受众校验里一条拒绝)。 - **先看**:① `packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts` 的 `onUpdate`(以写后行判定,覆盖按谓词的多行更新)与它的系统写入说明;② `packages/plugins/plugin-auth/src/audience-posture.ts` 中 `admin_full_access` 拒绝旁新增的智能体上限拒绝;③ `content/docs/permissions/system-context.mdx` 的 23f 行。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-security exec vitest run src/agent-ceiling-grant-refusal.test.ts`,预期 `Tests 14 passed (14)`。 - **需要你做的**:无,席位落地。 ## What changes - **The rule (ADR-0090 D10).** The MCP agent ceiling sets (`MCP_AGENT_PERMISSION_SETS`, imported from `@objectstack/spec/ai`, never retyped) are an AI agent's own grants: the door that admits an agent connected over MCP derives the ceiling from the scopes its user consented to and puts it on the agent's request. No assignment row is read for it, and no `sys_user` row is an agent principal, so every `sys_user_permission_set` row is a person's grant and never names one. - **Where it holds.** A new module, `packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts`, registers a `beforeInsert` and a `beforeUpdate` engine hook on `sys_user_permission_set` (priority 35: after the catalog check that the name is a set at all, before the holder's membership is read). Every door that writes the table writes through the engine, so each one is covered: the data API's create and update, `createMany`, `updateMany`, the per-object and cross-object batch, and the sync and job import doors. - **Which rows are judged.** An insert judges the row's set. An update judges the set the row holds after the write: the payload's `permission_set` when it carries one, the stored one when it does not. So an update that keeps an assignment naming a ceiling in force (a new holder, a validity window, an echo of the set) is refused as well as a rename to a ceiling, by id and by predicate. - **The self-registration set (`@objectstack/plugin-auth`).** A self-registrant is a person, so `assertAudienceConfig` refuses an agent ceiling set as `selfRegistrationPermissionSet`, beside its refusal of `admin_full_access`, with the message that refusal uses (`[audience] invalid audience configuration: selfRegistrationPermissionSet must not be '...'`). It applies at construction (`auth.audience` in the stack config stops the boot) and on the auth settings patch (`audience_self_registration_permission_set` is refused and the standing audience keeps ruling). `AudienceConfigSchema` in `@objectstack/spec` is not touched by this PR. - **What it leaves alone.** A delete is never judged. A system write (`isSystem`) is not judged. Writer by writer: the platform-admin promotion and the organization-admin reconcile name their sets in code, and neither is a ceiling; the self-registration grant writes only the validated `selfRegistrationPermissionSet`; seed replay and the development-only demo-staff seed write the sets their stack declares, unjudged. Every other permission set stays assignable. An agent principal's ceiling is resolved exactly as before. An organization administrator is still refused the same sets on the organization boundary first (`403 PERMISSION_DENIED`). - **The envelope.** `validationFailure` (the constructor both HTTP doors map) with one `fields[]` entry: `field: 'permission_set'`, `code: 'invalid_value'` (the ADR-0114 member: the set exists, so it is not `reference_not_found`), `value`, and a sentence naming the way through. No new error code (ADR-0112). - **Registration.** `security-plugin-start-hooks.ts` calls `registerAgentCeilingGrantRefusal(ql, ctx.logger)` beside the permission-set name hooks. It is idempotent on a re-run of `start()`, and warns and returns `false` on an engine with no hook registry. - **Public surface.** Unchanged: none of the new module's symbols is re-exported from the package entry (measured on the built `dist/index.d.ts` and `dist/index.d.mts`: 0 hits for each of the five names, positive control `SecurityPlugin` 24 hits each). - **Changeset.** `.changeset/sec-hardening-r6-agent-ceiling-grant.md`, `patch` on `@objectstack/plugin-security` and `@objectstack/plugin-auth` with a BREAKING banner, both declaration lines, its ADR-0087 disposition, and the fix for a deployment that holds such an assignment (delete it, or rename it to a set meant for people) or names such a set for self-registrants (declare a set meant for people). - **System-context register.** `content/docs/permissions/system-context.mdx` gains row 23f. Its Get cell names, writer by writer, what keeps a ceiling out of a system write (the fixed-name writers by construction, the self-registration set by the audience validation); its Lose cell names the seeds, which write what their stack declares. A non-system write that would store an assignment naming an MCP agent ceiling set is refused for every caller. Its generated site register now names `agent-ceiling-grant-refusal.ts#onInsert` and `#onUpdate` under that row (regenerated with `node scripts/check-system-context-census.mjs --write`). ## Tests - **`packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.test.ts`** (new, 14 tests; real ObjectQL engine, SQL driver on in-memory SQLite, the real `SecurityPlugin` started on it): - each of the three ceiling sets is refused on insert by a platform administrator (`400`, `VALIDATION_FAILED`, one field entry `permission_set` / `invalid_value` / the value), and nothing is stored; - a batch insert with one ceiling row among person grants is refused whole; - an update by id renaming a grant to a ceiling, and a predicate update renaming several, are refused and leave every row untouched; - an update that keeps a stored ceiling assignment in force (new holder, new window, echo of the set, by predicate) is refused; - controls: a person's set is assignable; renaming a stored ceiling assignment to a person's set, and deleting one, both land; system inserts and a system update stand down; an agent principal still resolves its ceiling with no assignment row while a person resolves none; - registration is idempotent, and an engine with no hook registry gets `false` and one warning. - **`packages/plugins/plugin-auth/src/audience-posture.test.ts`** (beside the `admin_full_access` pin): each of the three ceiling sets is refused as `selfRegistrationPermissionSet` under `open` and `email_domain`, with the message pinned; control: `member_default` and `portal_user` stay declarable. **`audience-posture-setting.test.ts`**: a ceiling set stored as `audience_self_registration_permission_set` is refused through the settings channel and the standing `invite_only` keeps ruling. - **`packages/qa/dogfood/test/grant-table-write-contract.dogfood.test.ts`** (new top-level block, 5 tests on the booted showcase stack over HTTP, sharing the file's one showcase boot with the existing block; its `beforeAll` asserts the shipped catalog serves all three ceiling sets, so the catalog check does not answer first): - `POST` of each ceiling set, and a `PATCH` renaming a person grant to one, answer the full envelope and store nothing; - `createMany` and the cross-object `/batch` answer the full envelope; - `updateMany` and the per-object batch fail the row in their per-row report with `code: 'VALIDATION_FAILED'`, `httpStatus: 400`; - the sync import and the import job fail the row with `code: 'invalid_value'` at `permission_set`. - **Ablation, plugin-auth.** With the new refusal's condition replaced by `false` (via `scripts/ablation-replace.mjs`, restored automatically): 4 failed / 75 passed across the two files (the three ceiling pins and the settings-channel pin red, the person-set control and every other pin green); restored, the file's blob equals the committed one and both files pass. - **Ablation, plugin-security.** With the guard line replaced by a no-op (via `scripts/ablation-replace.mjs`, restored automatically): the unit file goes 7 failed / 7 passed (every refusal pin red, every control green), and the dogfood block, rebuilt and checked with `scripts/ablation-dist-preflight.mjs`, goes 5 failed / 3 passed (the 3 are the file's existing grant-table pins), and each of the four door pins after the first, run alone so no earlier row feeds a later one, also goes red. Restored and rebuilt, both are green again. ## Acceptance notes - **The per-row report doors carry no `fields[]`.** `updateMany` and the per-object batch answer `200` with a per-row report whose failed row carries the refusal's `code` and `httpStatus` but not its `fields[]` entry; that is those doors' existing report shape for every validation refusal, not something this change introduces, so it is pinned as it stands. - **The dogfood file boots the showcase once.** Both blocks share one file-level boot and sign up their own users. Measured on a shared agent box: 13.31 s and 13.52 s with two boots; 10.05, 9.64, 9.78, 9.21 and 9.00 s with one. That is at the 10 s line, so it carries no `test-budget:` header; a CI reading over it would be the case for one. - **Stored assignments are not migrated.** An assignment stored before this release that names a ceiling set is left in place and can no longer be edited; the changeset gives the query to list them and the two ways to clear one. - **Clause-② spelling.** The diff narrows the accept set and widens nothing (no new export reaches the package entry), so the declaration is `no (narrowing)`. ## Verification Head: `e1305f7524` (branch `claude/sec-hardening-r6`, merged with `main` at 6701c08). The plugin-security ablation readings were taken on `1b9179369a`, and that module's code is unchanged since (its note was reworded). The plugin-auth ablation ran on `398fe7f1d6` (the one commit after it changes only the dogfood test file). Both package suites, the battery and the gates ran at `e1305f7524`. <details><summary>plugin-auth pins: 79 passed; ablated: 4 failed, 75 passed</summary> ``` pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/audience-posture.test.ts src/audience-posture-setting.test.ts (the new refusal's condition replaced by `false` through scripts/ablation-replace.mjs, restored on exit; on commit 398fe7f) Test Files 2 failed (2) Tests 4 failed | 75 passed (79) red: the three ceiling names under open and email_domain, and the settings-channel pin green: the person-set control, the admin_full_access pin, every other pin restore: whole-tree git status --porcelain empty; the file's blob equals the committed blob (3f5e481) Test Files 2 passed (2) Tests 79 passed (79) ``` </details> <details><summary>plugin-security unit file: 14 passed; ablated: 7 failed, 7 passed</summary> ``` pnpm --filter @objectstack/plugin-security exec vitest run src/agent-ceiling-grant-refusal.test.ts (guard line replaced by a no-op through scripts/ablation-replace.mjs, restored on exit; head 1b91793) Test Files 1 failed (1) Tests 7 failed | 7 passed (14) red: the three single inserts, the batch insert, the rename by id, the predicate rename, the update that keeps a stored one in force green: the spec list, the person-set control, the rename-away and delete control, the system stand-down, the agent principal, idempotent registration, no hook registry restore: whole-tree git status --porcelain empty; sha256 of the module matches the pre-ablation hash ``` </details> <details><summary>Dogfood file: 8 passed on one boot; ablated through the built bundle: 5 failed, 3 passed</summary> ``` head 1b91793 (two boots then) ablated: pnpm --filter @objectstack/plugin-security build exit 0 node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security 'throw agentCeilingGrantError(name)' --absent marker absent from all 6 built files exit 0 pnpm --filter @objectstack/dogfood exec vitest run test/grant-table-write-contract.dogfood.test.ts Tests 5 failed | 3 passed (8) exit 1 each of PATCH, createMany+/batch, updateMany+per-object batch, both imports run alone with -t: Tests 1 failed | 7 skipped (8) exit 1 each restored: whole-tree git status --porcelain empty; rebuilt; marker present in 2 built files; 8 passed (8) one boot (final file): 8 passed (8) on each of five runs; file line 10052, 9640, 9778, 9209, 9003 ms ``` </details> <details><summary>Package suites: plugin-auth 4018 passed, plugin-security 5407 passed; typecheck clean</summary> ``` pnpm --filter @objectstack/plugin-auth test exit 0 Test Files 224 passed (224) Tests 4018 passed | 10 skipped (4028) pnpm --filter @objectstack/plugin-security test exit 0 Test Files 246 passed (246) Tests 5407 passed | 45 skipped (5452) pnpm --filter @objectstack/dogfood --filter @objectstack/plugin-auth --filter @objectstack/plugin-security typecheck exit 0 ``` </details> <details><summary>Full dogfood battery at e1305f7: 420 files passed, 3505 tests passed</summary> ``` pnpm --filter @objectstack/dogfood test (no OS_TEST_SHARD, full closure built first, under the verify lock) exit 0 Test Files 420 passed | 3 skipped (423) Tests 3505 passed | 36 skipped (3541) ``` </details> <details><summary>Derived gates at e1305f7: 93 derived, 93 run, 0 not measured; all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 93 commands for this change set (nine paths); `--ran` on the record: 93 run, 0 NOT-MEASURED, 0 UNRUN. Each exit code was captured before any pipe. | exit | command | |---|---| | 0 | `node scripts/check-adr-0087-registration.mjs --base origin/main` | | 0 | `node scripts/check-adr-0087-registration.mjs --self-test` | | 0 | `node scripts/check-adr-links.mjs` | | 0 | `node scripts/check-adr-links.mjs --self-test` | | 0 | `node scripts/check-changeset-no-major.mjs --base origin/main` | | 0 | `node scripts/check-closing-keyword-parity.mjs` | | 0 | `node scripts/check-closing-keyword-parity.mjs --self-test` | | 0 | `node scripts/check-comment-mask-adoption.mjs` | | 0 | `node scripts/check-comment-mask-adoption.mjs --self-test` | | 0 | `node scripts/check-comment-mask-corpus.mjs` | | 0 | `node scripts/check-dev-prereqs.mjs --self-test` | | 0 | `node scripts/check-doc-route-spelling.mjs --advisory` | | 0 | `node scripts/check-doc-route-spelling.mjs --self-test` | | 0 | `node scripts/check-docs-section-name.mjs` | | 0 | `node scripts/check-docs-section-name.mjs --self-test` | | 0 | `node scripts/check-dts-emitted.mjs --self-test` | | 0 | `node scripts/check-empty-changeset.mjs --base origin/main` | | 0 | `node scripts/check-empty-changeset.mjs --self-test` | | 0 | `node scripts/check-issue-citations.mjs` | | 0 | `node scripts/check-plugin-teardown-shape.mjs` | | 0 | `node scripts/check-plugin-teardown-shape.mjs --self-test` | | 0 | `node scripts/check-section-landing-index.mjs` | | 0 | `node scripts/check-section-landing-index.mjs --self-test` | | 0 | `node scripts/check-tenant-audit-census.mjs` | | 0 | `node scripts/check-tenant-audit-census.mjs --self-test` | | 0 | `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` | | 0 | `node scripts/ci/plan-test-core-shards.mjs --self-test` | | 0 | `node scripts/ci/prune-turbo-cache.mjs --self-test` | | 0 | `node scripts/docs-audit/check-affected-docs.mjs` | | 0 | `node scripts/docs-audit/check-drift-comment.mjs` | | 0 | `node scripts/pm/release-rehearsal-clone.mjs --self-test` | | 0 | `node scripts/release-pending-publish.mjs --self-test` | | 0 | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` | | 0 | `pnpm --filter @objectstack/lint run check:doc-security-posture` | | 0 | `pnpm --filter @objectstack/spec run check:docs` | | 0 | `pnpm --filter @objectstack/spec run check:duration-unit-keys` | | 0 | `pnpm --filter @objectstack/spec run check:empty-state` | | 0 | `pnpm --filter @objectstack/spec run check:liveness` | | 0 | `pnpm --filter @objectstack/spec run check:skill-examples` | | 0 | `pnpm --filter @objectstack/spec run check:strictness-ledger` | | 0 | `pnpm --filter @objectstack/spec run check:variant-docs` | | 0 | `pnpm --filter @objectstack/spec run check:yaml-examples` | | 0 | `pnpm check:changeset-gate-self-tests` | | 0 | `pnpm check:ci-filter-parity` | | 0 | `pnpm check:closed-ceremony-text` | | 0 | `pnpm check:corpus-claim-drift` | | 0 | `pnpm check:cross-package-test-inputs` | | 0 | `pnpm check:dispatcher-error-vocabulary` | | 0 | `pnpm check:doc-anchors` | | 0 | `pnpm check:doc-authoring` | | 0 | `pnpm check:doc-frontmatter` | | 0 | `pnpm check:docs-audit-scope` | | 0 | `pnpm check:docs-redirects` | | 0 | `pnpm check:docs-single-h1` | | 0 | `pnpm check:docs-spec-enumerations` | | 0 | `pnpm check:docs-transcript-drift` | | 0 | `pnpm check:driver-memory-census` | | 0 | `pnpm check:dts-closure` | | 0 | `pnpm check:dual-build-cjs-loads` | | 0 | `pnpm check:engine-double-contract` | | 0 | `pnpm check:gitlink-declared` | | 0 | `pnpm check:i18n` | | 0 | `pnpm check:i18n-stale-fill` | | 0 | `pnpm check:issue-citations` | | 0 | `pnpm check:keyed-text-bounds` | | 0 | `pnpm check:lean-entry-closure` | | 0 | `pnpm check:logger-receiver-detach` | | 0 | `pnpm check:merge-driver` | | 0 | `pnpm check:nul-bytes` | | 0 | `pnpm check:objectql-double-limit` | | 0 | `pnpm check:org-identifier` | | 0 | `pnpm check:page-declaration-shape` | | 0 | `pnpm check:platform-object-tenancy-census` | | 0 | `pnpm check:published-files` | | 0 | `pnpm check:published-readme-links` | | 0 | `pnpm check:query-options-erasure` | | 0 | `pnpm check:react-page-adapter-contract` | | 0 | `pnpm check:refd-timer-probe` | | 0 | `pnpm check:registry-log-declared` | | 0 | `pnpm check:rest-log-spy-declared` | | 0 | `pnpm check:role-word` | | 0 | `pnpm check:skill-identifier-liveness` | | 0 | `pnpm check:slot-lookup` | | 0 | `pnpm check:sourcemap-no-sources-content` | | 0 | `pnpm check:system-context-census` | | 0 | `pnpm check:test-source-alias` | | 0 | `pnpm check:tier-file-adoption` | | 0 | `pnpm check:type-check-coverage` | | 0 | `pnpm check:type-check-debt` | | 0 | `pnpm check:undeclared-dep-imports` | | 0 | `pnpm check:vendor-version-stamps` | | 0 | `pnpm check:watch-hint-literal` | | 0 | `pnpm check:where-matcher` | </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg <!-- signed: security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3d2f4b commit 6dd2ca5

9 files changed

Lines changed: 752 additions & 13 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
fix(plugin-security, plugin-auth)!: a person-to-permission-set assignment refuses an agent ceiling set at every write door, and so does the self-registration permission set
7+
8+
Clause-②: no (narrowing) — a non-system insert or update of `sys_user_permission_set` that would store an assignment naming one of the three MCP agent ceiling sets (`mcp_agent_data_read`, `mcp_agent_data_write`, `mcp_agent_restricted`) is refused with `400 VALIDATION_FAILED` (`invalid_value` at `permission_set`), for every caller, a platform administrator included; and an audience configuration whose `selfRegistrationPermissionSet` names one is refused (`[audience] invalid audience configuration`), at construction and through the auth settings; nothing is widened.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed or re-shaped: a runtime write of an assignment row naming an agent ceiling set is refused and a stored one is left for an administrator to remove, and the runtime audience validation refuses one as the self-registration set, which takes a person's set chosen by hand, not a mechanical conversion -->
12+
13+
**BREAKING** for a deployment whose administrators assign an MCP agent ceiling set to users through the permission-set assignment table, or whose audience names one as `selfRegistrationPermissionSet`.
14+
15+
- **The rule (ADR-0090 D10).** The agent ceiling sets are an AI agent's own grants. The door that admits an agent connected over MCP derives its ceiling from the scopes its user consented to and puts it on the agent's request; no assignment row is read for it. A `sys_user_permission_set` row is a person's grant, so it never names one. Every door that writes the table refuses such a row: the data API's create and update, `createMany`, `updateMany`, the per-object and cross-object batch, and both import doors (the row fails with `code: 'invalid_value'` at `permission_set`). An update that keeps an assignment naming one in force (a new holder, a validity window, an echo of the set) is refused too.
16+
- **The self-registration set (`@objectstack/plugin-auth`).** A self-registrant is a person, so the audience configuration that names the set every self-registrant receives refuses an agent ceiling set, beside its existing refusal of `admin_full_access`: `auth.audience.selfRegistrationPermissionSet` in the stack config stops the boot, and `audience_self_registration_permission_set` in the auth settings is refused while the standing audience keeps ruling. Both answer `[audience] invalid audience configuration: selfRegistrationPermissionSet must not be '...'`.
17+
- **Unchanged.** Every other permission set stays assignable, and removing an assignment stays open. A system write is not judged: the platform's fixed-name grant writers name no ceiling, the self-registration grant writes only the validated set, and seed replay and the development-only demo-staff seed write the sets their stack declares. An agent connected over MCP holds its ceiling exactly as before. An organization administrator is still refused the same sets on the organization boundary first (`403 PERMISSION_DENIED`).
18+
- **Who moves, and the fix.** An assignment stored before this release that names an agent ceiling set is left as it is, and can no longer be edited: delete it (`DELETE /api/v1/data/sys_user_permission_set/ID`), or rename it to a permission set meant for people. List them with `GET /api/v1/data/sys_user_permission_set?permission_set=mcp_agent_data_read` and the same query for the other two names. An audience that names an agent ceiling set as `selfRegistrationPermissionSet` declares a permission set meant for people instead (`member_default`, or the deployment's own).

‎content/docs/permissions/system-context.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ bugs — it is the protection or the side effect that silently does not happen.
139139
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onUpdate` |
140140
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onUpdate` |
141141
| 23e | **A `secret` field takes a stored reference as given** — a value already spelled `secret:` is stored unchanged | objectql | Get: a system writer may copy a `secret:` reference onto a record as it stands (seed replay, a migration). Every other caller may only seal a new value or keep its own record's ref: on a create any such value is refused, and on an update it is accepted only when every record the write lands on already holds exactly that reference in that column, which is then left as it is, as an echoed read mask is. Any other reference is refused `400 VALIDATION_ERROR` before anything is written, with one answer whatever it names. Lose: the check that the reference is the record's own — a system writer that copies one is trusted to mean it. Inbound HTTP never carries the flag | `encryptSecretFields` |
142+
| 23f | **Agent ceiling grant check skipped** — a `sys_user_permission_set` write naming an MCP agent ceiling set is not refused | plugin-security | Get: a system writer is not judged. The platform-admin promotion and the organization-admin reconcile name their sets in code, and neither is a ceiling; the self-registration grant writes the configured `audience.selfRegistrationPermissionSet`, which the audience validation (`assertAudienceConfig`, plugin-auth) refuses when it names an agent ceiling set. Lose: seed replay and the development-only demo-staff seed write the sets their stack declares, unjudged, so a seed that names an agent ceiling set stores it. A non-system insert, or a non-system update whose resulting row names one, is refused `400 VALIDATION_FAILED` / `invalid_value` at `permission_set`, for every caller, a platform administrator included; a delete is not judged (ADR-0090 D10) | `packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts#onInsert`, `packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts#onUpdate` |
142143
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `buildDriverOptions` |
143144
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `packages/plugins/plugin-security/src/system-write-guard.ts#assertEngineOwnedWriteAllowed` |
144145
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
@@ -578,6 +579,8 @@ and CI refuses one that differs from what the tree produces.
578579
| `packages/plugins/plugin-auth/src/owner-fixed-rule.ts#isOwnerChange` | 1 | 26c |
579580
| `packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront` | 1 | 61b |
580581
| `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite` | 1 | 59 |
582+
| `packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts#onInsert` | 1 | 23f |
583+
| `packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts#onUpdate` | 1 | 23f |
581584
| `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` | 1 | 1b |
582585
| `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert` | 1 | 23d |
583586
| `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onUpdate` | 1 | 23d |

‎packages/plugins/plugin-auth/src/audience-posture-setting.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import { describe, it, expect, vi, beforeEach } from 'vitest';
3737
import type { PluginContext } from '@objectstack/core';
3838
import { AUDIENCE_POSTURES } from '@objectstack/spec/system';
39+
import { MCP_AGENT_PERMISSION_SET_WRITE } from '@objectstack/spec/ai';
3940
import { AuthPlugin } from './auth-plugin.js';
4041
import { AuthManager } from './auth-manager.js';
4142
import { OPEN_POSTURE_VERIFICATION_OFF_WARNING } from './audience-posture.js';
@@ -232,6 +233,19 @@ describe('auth.audience_* — the settings switch surface (#11768)', () => {
232233
expect(errorLines().some((m: string) => m.includes('admin_full_access'))).toBe(true);
233234
});
234235

236+
it('an agent ceiling set as the self-registration permission set is refused through the settings channel', async () => {
237+
const { manager } = await boot({
238+
settings: {
239+
audience_posture: { value: 'open', source: 'global' },
240+
audience_self_registration_permission_set: { value: MCP_AGENT_PERMISSION_SET_WRITE, source: 'global' },
241+
},
242+
});
243+
expect(manager.getAudience().posture).toBe('invite_only'); // standing default keeps ruling
244+
const logged = errorLines();
245+
expect(logged.some((m: string) => m.includes('audience settings REFUSED'))).toBe(true);
246+
expect(logged.some((m: string) => m.includes(`selfRegistrationPermissionSet must not be '${MCP_AGENT_PERMISSION_SET_WRITE}'`))).toBe(true);
247+
});
248+
235249
it('open with verification OFF stored only through the CONSOLE is refused — the console cannot make the opt-out', async () => {
236250
// The audience patch is applied AFTER the main patch, so the merged-result
237251
// validation judges it against the `require_email_verification: false`

‎packages/plugins/plugin-auth/src/audience-posture.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import { describe, it, expect, vi } from 'vitest';
2323
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql';
2424
import { ERROR_CODE_LEDGER } from '@objectstack/spec/api';
25+
import { MCP_AGENT_PERMISSION_SETS } from '@objectstack/spec/ai';
2526
import { AuthManager } from './auth-manager';
2627
import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit.js';
2728
import {
@@ -452,6 +453,24 @@ describe('entry validation: assertAudienceConfig (#11739)', () => {
452453
).toThrow(/admin_full_access/);
453454
});
454455

456+
it.each(MCP_AGENT_PERMISSION_SETS)('refuses the agent ceiling set %s as the self-registration permission set, under each self-registration posture', (name) => {
457+
const domains = { allowedEmailDomains: ['acme.com'] };
458+
for (const audience of [{ posture: 'open' as const }, { posture: 'email_domain' as const, ...domains }]) {
459+
expect(
460+
() => assertAudienceConfig({ ...audience, selfRegistrationPermissionSet: name }, undefined),
461+
audience.posture,
462+
).toThrow(
463+
new RegExp(`^\\[audience\\] invalid audience configuration: selfRegistrationPermissionSet must not be '${name}' — it is the permission ceiling of an AI assistant connected over MCP`),
464+
);
465+
}
466+
});
467+
468+
it('control: a permission set meant for people stays a declarable self-registration set', () => {
469+
for (const set of ['member_default', 'portal_user']) {
470+
expect(() => assertAudienceConfig({ posture: 'open', selfRegistrationPermissionSet: set }, undefined), set).not.toThrow();
471+
}
472+
});
473+
455474
it('email_domain: an explicit verification-off stays refused, from ANY declarant (the domain list is the only gate there)', () => {
456475
const emailDomain = {
457476
posture: 'email_domain' as const,

‎packages/plugins/plugin-auth/src/audience-posture.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
*/
9191

9292
import { isCloudManaged } from '@objectstack/core';
93+
import { MCP_AGENT_PERMISSION_SETS } from '@objectstack/spec/ai';
9394
import {
9495
AUDIENCE_POSTURES,
9596
SystemUserId,
@@ -217,8 +218,11 @@ export const OPEN_POSTURE_VERIFICATION_OFF_WARNING =
217218
* Entry validation — REFUSES an unusable audience declaration at the point it
218219
* enters the manager (constructor and `applyConfigPatch`), loudly, with the
219220
* remedy in the message. Mirrors `AudienceConfigSchema`'s predicates (the
220-
* schema guards the authoring surface; the runtime receives plain objects) and
221-
* adds the one cross-field invariant the schema cannot see, on
221+
* schema guards the authoring surface; the runtime receives plain objects),
222+
* refuses one value the schema does not — an MCP agent ceiling set as the
223+
* self-registration set ([ADR-0090 D10]: a self-registrant is a person, and an
224+
* agent ceiling is not a person's set) — and adds the one cross-field invariant
225+
* the schema cannot see, on
222226
* `emailAndPassword.requireEmailVerification` (verification is FORCED on by
223227
* the wiring under both self-registration postures unless noted):
224228
*
@@ -308,6 +312,15 @@ export function assertAudienceConfig(
308312
'self-registrant is never a declarable audience.',
309313
);
310314
}
315+
// [ADR-0090 D10] A self-registrant is a person, and an agent ceiling is not
316+
// a person's set: the declaration that names the set refuses one.
317+
if (typeof set === 'string' && (MCP_AGENT_PERMISSION_SETS as readonly string[]).includes(set)) {
318+
fail(
319+
`selfRegistrationPermissionSet must not be '${set}' — it is the permission ceiling of an AI assistant ` +
320+
'connected over MCP, which the assistant receives from the access its user consented to, never from an ' +
321+
'assignment, and a self-registrant is a person. Declare a permission set meant for people.',
322+
);
323+
}
311324
if (emailAndPassword?.requireEmailVerification === false) {
312325
if (posture === 'email_domain') {
313326
fail(

0 commit comments

Comments
 (0)