Skip to content

feat(core,plugin-security)!: 18.x step 7 part 7a-2 — a grant applies only in its own organization; the organization-less grant readers retire - #24872

Merged
objectstack-fleet[bot] merged 59 commits into
mainfrom
claude/issue-24583-7a2-authz-null-readers
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 59 commits into
mainfrom
claude/issue-24583-7a2-authz-null-readers

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #24583 — step 7, part 7a-2: the organization-less readers of the authorization resolver and plugin-security retire. The card stays open for the other step-7 parts (7a-1, 7a-3, 7b-1, 7b-2, 7b-3, 7c).

Clause-②: yes (narrowing) — measured: a stored grant row with no organization_id no longer applies in any resolution, and no longer anchors platform standing.

What changes

@objectstack/core — resolve-authz-context.ts

  • grantAppliesInTenant(org, tenantId) is now org !== null && org === tenantId. A sys_user_position (§4) or sys_user_permission_set (§6) row applies only in its own organization; with no active organization, no stored grant applies. §4 now reads the row through grantOrganizationOf, so a blank value is normalized to null like everywhere else.
  • grantOrganizationOf stays as the normalizer; null (NULL, absent or blank) confers nothing.
  • grantAnchorsPlatformStanding loses its organization-less arm: under single, only the Default Organization's admin_full_access grant anchors platform_admin. That is the grant the first-user promotion already writes. It still applies in every organization, or with none active. Walled postures are unchanged (no grant anchors there).
  • mayApply / applyingGrants keep their shape and now read the narrowed rule; their docblocks are corrected.

@objectstack/plugin-security

  • delegated-admin-gate.ts: holdingTakesEffectIn counts a holding only when it belongs to the caller's active organization. That is exactly what the resolver grants, so an organization-less holding, or a caller with no active organization, can no longer self-delegate. The org-admin removal of an organization-less row no longer claims the row "applies in every organization".
  • bootstrap-platform-admin.ts: isAnchorHumanHolder matches the Default Organization's grant only. Leg A asks once, for the Default Organization's id as organization_id, never for null. When no Default Organization can be named (a wall, or none to name), the holder scan reads no grant at all. The walled "no owner email" error loses its sentence about a legacy unscoped grant row, which can no longer be found.
  • organization-scope.ts: resolveOwnOrganizationRow returns { own }; the residue half is gone.
  • grant-permission-set-name.ts: the dead export grantOrganizationOf is deleted. It was not re-exported from the package and had no importer.

Two text corrections this change made false (a declared extension of the claim's file surface, text only):

  • core/src/security/admin-standing-surface.ts: the module docblock and the sys_user_permission_set reason no longer name the organization-less row as an anchor.
  • runtime/src/domains/automation.ts: under single, the run-lifecycle refusal named "an unscoped admin_full_access grant" as the remedy. It now names the Default Organization's grant.

Pins and fixtures. Every pin that asserted the retired reading is rewritten to the new rule, each with a positive control in the same test. Every fixture that relied on an organization-less grant to exercise something else is re-stamped into an organization (with the resolution run in it) or into the Default Organization. No assertion is loosened, no test is skipped, and no test is deleted. These live in 16 packages, because the readers' pins reach that far: core, plugin-security, plugin-auth, runtime, rest, cloud-connection, service-datasource, service-settings, service-automation, plugin-hono-server, plugin-approvals and plugin-sharing (test files only), and dogfood. Five suites gained a new negative pin, "an organization-less grant confers / anchors nothing".

Premise check — the retired readers are dead after certification

tree measurement result
fresh single install (pnpm dev -- --fresh, merged head) sys_user_permission_set / sys_user_position rows with NULL or blank organization_id 0 of 2 / 0 of 5; the one admin_full_access grant is owned by the Default Organization
walled (isolated) os serve, real Organizations runtime same tables 0 rows at all; no grant row is written under a wall
ceremonied 18.0 fixture (R2 harness) expectCertified: zero NULL organization_id rows; the fixture's admin then signs in and reads green
composition with no organization object (no-organization-object) does any shipped composition reach it? no — os serve registers SecurityPlugin only inside the AuthPlugin block (which ships sys_organization); no example, template or os init output composes plugin-security without it. In such an embedding, grants stay organization-less and now confer nothing (the changeset says so).

Verification (head 21b172681: origin/main at 81120caac merged in, carrying #24681 12d14765c, 7a-1 7c2eab929, 7c bd6dab009, 7a-3 7cacad534 and 7b-3 df8bb40a0)

The full set below ran on a62067ad4 (main with #24681 and 7a-1). The two later merges (7c, 7a-3, 7b-3 and the commits around them) were re-checked on the packages they touch: plugin-sharing, plugin-approvals, plugin-auth, runtime, service-settings, plugin-hono-server and CLI unit, the merged sharing and settings dogfood files, spec check:generated, R1, R4 live, and the harness. All green on 21b172681.

reading result
R1 — fresh single boot, random port health 200, seeded-admin sign-in 200, session carries platform_admin, 0 error lines
R2 + R3 — upgrade-18x-harness + organization-ownership-boot-refusal-order dogfood 2 files / 12 passed (on every merged head)
R4 — isolated os serve, throwaway app declaring the real @objectstack/organizations banner reached, Organizations mounted, discovery 200, 0 error lines; a non-owner admin resolves everyone only
R4 — step 4's 7 walled e2e cases (OS_TEST_TIERS=nightly) 2 files / 7 passed
package suites, whole core 2680 · plugin-security 4056 · plugin-auth 3264 · runtime 7594 · rest 6424 · plugin-approvals 1170 · plugin-sharing 1201 · service-automation 2604 · plugin-hono-server 422 · cloud-connection 614 · service-datasource 852 · service-settings 899 · organizations 166 · trigger-api 55 · trigger-record-change 114 · trigger-schedule 174 · CLI --project unit 4635 — 0 failed
dogfood, the 71 files that touch grant tables 71 files / 696 passed, 1 skipped (one earlier full run went red once on showcase-crud-persona-matrix: its probe picked a paid invoice. That is order-dependent and unrelated, it passed alone, and main fixed it in 08d201a80)
dispatch-gates --commands 79 derived / 79 run, each with its exit code; 79 exit 0 (check-plugin-teardown-shape --self-test needed its pinned commit fetched into this shallow clone, then exit 0)
typecheck of the touched packages exit 0 for all 13 touched packages (core, plugin-security, plugin-auth, runtime, rest, cloud-connection, service-datasource, service-settings, service-automation, plugin-hono-server, plugin-approvals, plugin-sharing, dogfood). runtime/test-typecheck-debt.json was re-recorded: one pinned TS7053 signature was renamed by a fixture's added table; the count is unchanged
check-platform-checklist --changed-from origin/main OK — 12 anchors judged, 0 lost

Reverse verification (ablation), core resolver, source path (core's suite imports its own src):

  • leg 1: grantAppliesInTenant back to organizationId === null || … → 8 failed / 145 passed across the three resolver suites. Exactly the organization-less and no-tenant pins went red. Restore proven by blob hash.
  • leg 2: grantAnchorsPlatformStanding given back its null arm → 5 failed / 170 passed. Exactly the anchor pins went red. Restore proven by blob hash.

Acceptance notes

  • Size. 100+ files and more than 3,000 changed lines (check-governed-merges --branch: 3,341). Almost all of it is test fixtures across the 16 packages that pin these readers. No governed path is touched, but the size rule applies: this lands by an authorized approval or the maintainer's merge.
  • Out-of-surface organization-less readers (not edited, for the seat):
    • plugin-auth/src/last-admin-guard.ts anchorOwnerTest (about line 1028) still counts an organization-less admin_full_access grant as a platform-admin holder;
    • plugin-auth/src/ensure-default-organization.ts (about lines 505–509) still asks organization_id: null when it picks the Default Organization's owner;
    • plugin-security/src/explain-engine.ts collectGrantProvenance reads holdings with no organization filter, so it can annotate a row the resolver no longer applies.
      On a certified or fresh database none of them is reachable, because no such row exists.
  • plugin-sharing (7a-3, not touched): assertResolvableAdminScope (sharing-rule-service.ts) is no longer reachable over HTTP by any grant. Its comment still describes the old reading.
  • Posture source split (pre-existing): the resolver reads the walled posture from OS_TENANCY_POSTURE, while cloud-connection's install door reads it from the tenancy service. The organization-less grant used to mask a disagreement between them.
  • Two fixture-design changes worth a look: service-automation/src/sys-user-default-row-scope.integration.test.ts binds its '*' reader to the everyone anchor, because its "no organisation" legs cannot be reached by a stored grant any more. service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts §4–§7 now pin that an organization-less API key passes the posture but holds nothing (403 rather than the posture's 401).

维护者速读(草稿)

  • 改了什么: 授权解析器与 plugin-security 不再把「没有组织的授权行」当成全局授权:一行授权只在它自己的组织里生效;平台管理员身份只认 Default Organization 名下的 admin_full_access 授权。
  • 为什么改: 18.0 升级仪式已把所有无组织的行归属到组织并设为 NOT NULL,全新安装也不会再写这种行,旧的读法已是死代码(本 PR 在全新安装、隔离墙启动、仪式后数据库上实测为零行)。
  • 风险与代价(含回滚): 只影响「不注册组织对象却挂 plugin-security」的嵌入式组合——这类组合的授权将不再生效;官方组合(os serve)不受影响。改动大(3,341 行,几乎全是测试夹具改戳),回滚即 revert 本 PR。
  • 席位意见:
  • 你要做的: 审阅后批准(超 3,000 行,需授权批准或由你合并);refactor(drivers,objectql,plugin-security)!: 18.x step 7: retire the organization-less readers (8c), COALESCE and __global__ (8d), and steps 1–3's dual reads #24583 的其余部分落地后再发 18.0.0。

Generated by Claude Code

claude added 30 commits October 19, 2026 13:06
…r retired; webhook activation door

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…he 18.0 dual read; door pins for webhook

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…asserts no row and a dispatch

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the checklist, the webhooks guide and the showcase; changeset

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…tcher's switch read

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…posture builds no wall, as its 18.0 seeded copy stamped with the Default Organization was

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…red writes; the declared-webhook double is drivable by the limit gate

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…does not implement

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…authors; checklist and admitsOrganization docblock say a declared webhook is deployment-wide under single

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the fixture's customized declared-webhook mirror

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…n fail on a double delivery

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…-registry' into claude/issue-24581-ceremony-open
…he ceremony, then boot; boot-refusal-order step 2 back on the binary
…el — the cipher names no organization

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
…sys_flow_credential leave reach)

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review (re-review after FAIL at 21b1726)

Served-tier: TIER_DEFAULT
Head-sha: e9cdb1de8d261b5fa088be0321a1d36fa0ff0468
Local-runs: none

In-seat delta review of PR #24872 (18.x step 7, part 7a-2). The FAIL at 21b172681 (6291176705) had one blocking item: B1, the WHERE-matcher gate. Its residuals were R1, stale text, and R2, the runtime bump. The new head adds commit 2ba92fc90 and one clean merge of main (b1676441b, which carries chore: version packages #24524). Every access-control judgment of the FAIL record stands unchanged.

① Derived judgments

  1. The delta is four files. Compared against each head's own merge-base and ignoring hunk headers, only these files changed hunks; the other 98 of the PR's 102 files are byte-identical:
    • the changeset;
    • core/src/security/admin-standing-surface.ts;
    • plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts;
    • runtime/src/domains/automation.ts.
  2. B1 is closed. In the holder-scan test, the sys_organization fake matcher now throws fake driver: unsupported operator on any $-prefixed key, in the shape the same file's sys_permission_set matcher already uses. The baseline is untouched. On the new head, Lint · CI, release & engine gates and the required Lint & Repo Gates are success; they were red at 21b172681.
  3. R1 is closed. admin-standing-surface.ts and automation.ts now name "the Default Organization's admin_full_access grant" in place of "an unscoped" one: in the run-lifecycle refusal text and in two docblocks. These are text changes only; no condition changed.
  4. R2 is closed. The changeset adds '@objectstack/runtime': patch, with one line for the refusal text.
  5. The upgrade harness ran on this PR's merge preview through the opt-in lane (ci: an opt-in label lane runs the 18.x upgrade harness on a line PR's merge preview (step 0 wiring) #24554): 18.x upgrade harness (opt-in) is success.

② Semver level

Unchanged: minor with BREAKING on core and plugin-security, plus patch on runtime. 18.0.0 is published (#24524), so this ships in the next release under the unchanged convention.

③ Boundary flags

Implemented-by: claude/issue-24583-7a2-authz-null-readers
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@os-zhuang
os-zhuang marked this pull request as ready for review October 20, 2026 13:43
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 20, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 · 请批准 · epic 席 session_01Y5583bs2RDd53MH6kM1ca3(os-steve)· 2026-10-20T13:44Z

需要你做的:在本 PR 点 Approve(Review changes → Approve)。 本 PR 改动 3,341 行,超过 3,000 行,属于 Tier H,要你批准后才能合入。批准后由 epic 席设为可审查、开启自动合并,你不用再做别的。

  • 改了什么: 18.x 第 7 步的 7a-2。权限判断不再把"组织为空的授权"当作"所有组织都生效":
    • 一条授权只在它所属的组织里生效;
    • 平台管理员身份只认默认组织的 admin_full_access 授权(引导流程本来就写这一条);
    • 委派管理员、平台管理员锚点和本组织查找里读空组织的分支都删掉了;
    • 一个从未对外导出的死函数也删掉了。
  • 为什么改: ADR-0131 D8 规定"任何一层都没有空组织分支"。升级仪式认证过的库和全新安装上,组织为空的授权条数都是 0,这些分支已经用不到。
  • 规模为什么大: 3,341 行里大部分是 16 个包的测试数据,要补上组织字段。生产代码只改了 7 个文件。
  • 验证:
    • 复审:独立复审先 FAIL,原因只有一个检查门禁;修复后再审 PASS(6292095870)。权限相关的判断全部通过:没有授权能跨组织生效;默认组织的普通成员拿不到平台管理员身份;多组织模式下平台所有者的锚点不受影响。
    • 开发者实测:全新安装、多组织模式、四条启动和升级规则全部通过,81 项检查全过。
    • 已发布 18.0.0 → 本 PR:用已发布的 18.0.0 建库,再用本 PR 启动,内置管理员仍是平台管理员,没有出现拒绝。
    • CI:必过检查都已通过,升级测试的 CI 通道(ci: an opt-in label lane runs the 18.x upgrade harness on a line PR's merge preview (step 0 wiring) #24554)也跑通了。
  • 风险与回滚: 只影响带组织为空授权的库,而认证过的库里没有这种授权。不含数据迁移;回滚即还原本 PR。
  • 随下一个版本发布: 18.0.0 已发布,本 PR 进下一版。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot requested a review from os-zhuang October 20, 2026 13:45
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 20, 2026
…2-authz-null-readers

# Conflicts:
#	packages/plugins/plugin-security/src/delegated-admin-gate.ts
#	packages/plugins/plugin-security/src/organization-scope.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat delta check · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-20T14:19Z. Head 86644000d546a512a2b3e0d801134ec68c9a4411 replaces e9cdb1de8.

Why it changed. The merge queue removed this PR at 2026-10-20T14:04Z. 7z (#24880, 0091d845e2) had landed comment edits in two files this PR also touches. The new head merges origin/main (a7199ef974) into e9cdb1de8 with one merge commit; there is no rebase or force-push.

Delta, measured:

  • This PR's own hunks, merge-base(old)..old against merge-base(new)..new, cover the same 102 paths. They are byte-identical, ignoring hunk headers, in 100 of them.
  • The other two are the conflicted files, plugin-security delegated-admin-gate.ts and organization-scope.ts. Between e9cdb1de8 and the new head, every changed line in those two files is a comment line. No code line moved.
  • The resolution text reads correctly with both parts on main: the driver's NULL arm is retired (D8, and check-tenant-chokepoint refuses it), the ceremony attributes rows and sets NOT NULL (D10), and a fresh install writes none (D9).
  • Dev gates on the new head:
    • check-tenant-chokepoint exit 0;
    • the whole plugin-security suite passes (4075 tests);
    • auth-manager.test.ts 296 passed;
    • typecheck and eslint on both files exit 0.

The review stands. The CONTRACT_REVIEW_TIER PASS recorded at e9cdb1de8 carries to this head; no reviewed line changed. The maintainer's approvals 5585893892 and 5585957848 (os-zhuang, a GOVERNED_APPROVERS account) remain APPROVED; the queue guard does not tie approval to commit_id. The seat re-arms auto-merge so the PR re-enters the queue when its checks are green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants