Repository navigation
Commit ed4b832
feat(plugin-auth): a SCIM group push grants the ordinary permission set its group mapping names (#31443)
Refs #30959
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:身份提供商经 SCIM 推送的组,现在可以按平台管理员保存的映射,给组成员授予一个普通权限集;成员移出组,授权随之撤销。
- 为什么改:此前 IdP 推送的组只落库、不授权,客户必须把每个组成员关系再手工授一遍权限集(卡 #30959,裁决「以上都同意」)。
- 风险与回滚:映射永远不能授予管理员身份,保存时用 plugin-security
自己的组织边界判定拒绝;手工授予的权限从不被对账删除。回滚即撤掉本 PR,没有映射时行为与今天一致。
-
先看:`packages/plugins/plugin-auth/src/scim-group-projection.ts`(对账只删带本投影标记的授权),`packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts`(保存时的边界拒绝),`packages/plugins/plugin-security/src/delegated-admin-gate.ts`(判定函数原样上提)。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/scim-group-projection.test.ts`,预期 14 个用例全绿。
- 需要你做的:无,席位落地。
## What changes
An identity provider that pushes a group over SCIM now changes what its
members may do, through a mapping a platform administrator saves.
- **Storage.** New platform object `sys_idp_group_mapping`
(`packages/platform-objects/src/identity/sys-idp-group-mapping.object.ts`):
one row per mapping with typed columns `source_kind` (closed select, V1
value `scim`, the seam #30967 reuses for SSO group claims), `source_id`
(the SCIM connection), `group_external_id` or `group_display_name`
(exactly one), `permission_set` and `organization_id`. The data API only
lists it. One row in `PLATFORM_OBJECTS_BY_PACKAGE`.
- **Writer.** `POST /api/v1/auth/admin/scim/set-group-mapping`
(`connectionId`, `groupExternalId` or `groupDisplayName`,
`permissionSet`) and `POST /api/v1/auth/admin/scim/delete-group-mapping`
(`mappingId`), behind the same platform-admin gate as
`/admin/scim/mint-credential`. Both are in the route ledger, its
conformance pin and the two dogfood admin-route censuses. The
organization is the one that owns the connection's live credentials
(active and unexpired, the same check the SCIM bearer verifier uses); a
connection with no credential is refused `reference_not_found`, one
whose every credential is revoked or expired, or whose live credentials
name no organization or two, `rule_violation`. Re-saving the same group
and set is idempotent in that organization; when the same-key row was
saved while another organization owned the connection, the save is
refused `409 RESOURCE_CONFLICT` naming that mapping (delete it first),
so the answer never names an organization the row does not carry.
- **Admin boundary.** The save refuses every set outside the
organization boundary with `400 VALIDATION_FAILED`, field
`permissionSet`, code `rule_violation`, and stores nothing:
`admin_full_access`, the organization-administrator sets, the agent
ceiling sets, a set with a superuser wildcard or an `adminScope`, and a
set with a capability not scoped to the organization. The judgement is
plugin-security's own `organizationBoundaryFailure`, lifted verbatim
from a private method of `DelegatedAdminGate` to a module function (the
method is now a one-line delegate) and served as an optional member of
the registered `security` service. plugin-auth takes no dependency on
plugin-security and keeps no copy of the list; it feature-detects the
member and fails closed (`503`, nothing saved, nothing granted).
- **Spec.** One optional member,
`ISecurityService.organizationBoundaryFailure?(setName: string)`,
resolving to the failure reason as a string or `null` when the set is
inside the boundary (`packages/spec/src/contracts/security-service.ts`),
declared in `registered-security-service-members.pin.test.ts`. Optional,
so every existing implementer still type-checks.
- **Mechanism.** `scim({ … })` in `auth-manager-plugin-list.ts` now
takes the vendor `projection` (`createScimGroupProjection`). `roles.map`
returns the connection's mapping rows that match the group (by external
id when the row names one, else by exact display name); `roles.exists`
re-judges the boundary at grant time; `reconcileUser` writes through the
ObjectQL engine inside the SCIM transaction with the request's system
context, so the audit actor is `svc:scim:CONNECTION_ID`. An engine
refusal becomes the vendor's `APIError`, never a SCIM 500.
- **Provenance.** A projected grant carries `reason` =
`scim-group-projection: provisioning domain 'ID'`. Reconcile deletes
only rows with that exact marker, so a grant made by hand, including one
for the same set, is never removed, and a set already held by hand is
not written again. Saving or deleting a mapping re-runs the vendor's
`reconcileSCIMProjection` for the connection, so the change applies to
current members at once.
- **Last-admin guard.** Its texts no longer tell operators that "the
SCIM group mapping is too broad": a mapping never moves administrator
standing, so the refusal now says to grant another account administrator
standing first.
- **Translations.** The new object's labels are translated in every
translated locale `main` ships (now nine, with pt-BR, ru and de); the
bundles are regenerated, and `bundle-ownership.test.ts` lists the
object.
- **Ownership ceremony.** `@objectstack/cli`'s ADR-0131 D10 inventory
(`organization-ownership-inventory.ts`) gains a row for
`sys_idp_group_mapping` with the attribution fate, like
`sys_connector_connection` beside it: every mapping row is written with
its organization, so a NULL row has no producer. Without it `os migrate
organization-ownership` refused any database holding the new table.
- **Censuses.** The tenant-audit census
(`content/docs/permissions/tenant-audit-census.mdx` and its counts file)
and the platform-object tenancy census are regenerated for the three new
write sites and the new object; the page's hand-written figures are
updated to the census.
**Follow-up:** the ADR recording this decision (mechanism, storage,
admin boundary, vendor `@better-auth/scim` 1.7.12) plus one pointer line
in ADR-0134 D4 comes as a second PR from this branch after this one
merges, so its symbol anchors resolve against landed code. That PR says
`Fixes #30959`, so the card stays open until the ADR lands (this build
PR only refers to it).
## Tests
`packages/plugins/plugin-auth/src/scim-group-projection.test.ts` drives
the real producer: `AuthManager.handleRequest()` with a real SCIM bearer
over `@better-auth/scim` 1.7.12, on a real ObjectQL engine over
better-sqlite3, with mappings saved through the door's own function and
judged by the real plugin-security `security` service over a test
catalog.
| Row | Pin | Ablation (mutation → red) |
|---|---|---|
| (1) mapped group push grants the set, actor `svc:scim:okta-30959` |
grant row + one insert with that actor | projection wiring removed →
red; writer without the SCIM context → red |
| (2) member removed → grant revoked | row gone + one delete with that
actor | projection wiring removed → red; writer without the SCIM context
→ red |
| (3) admin set refused at save | 400, `VALIDATION_FAILED`,
`[permissionSet, rule_violation]`, no row | boundary refusal disabled →
red |
| (4) set outside the organization boundary refused at save | same shape
| boundary refusal disabled → red |
| (5) provisioning with no mapping still works | SCIM create + PATCH
2xx, no grant | regression pin |
| CONTROL unmapped group | no grant | group match removed from
`roles.map` → red |
| CONTROL hand-made grants (`admin_full_access` + the mapped set by
hand) | untouched by push and removal | provenance marker check removed
→ red |
| save/delete reproject current members | grant appears on save, goes on
delete | projection wiring removed → red |
| (S1) connection whose credential names no organization refused at save
| 400, `VALIDATION_FAILED`, `[connectionId, rule_violation]`, no row |
door's no-organization check disabled → red |
| (S1) projection never writes a grant without an organization |
org-less mapping read: no grant, one warning | reconcile's `admits` call
removed → red; `admits` organization check disabled → red |
| (S2) reconcile judges the boundary itself: set redefined as
administrator after the save | `reconcileUser` called without
`roles.exists`: no grant, one warning; a real push then grants nothing |
reconcile's `admits` call removed → red |
| (S4) credential revoked in organization A, a new one minted in B |
save 200 into B, the row carries B | live-credential filter removed from
the door → red |
| (S4) every credential revoked | 400, `VALIDATION_FAILED`,
`[connectionId, rule_violation]`, no row | live-credential filter
removed from the door → red |
| (S5) re-save after revoke in A and mint in B | 409,
`RESOURCE_CONFLICT`, message names the A mapping and A, the single row
stays in A; CONTROL same-organization re-save answers 200 `created:
false` | organization match removed from the door → red |
Each ablation ran through `scripts/ablation-replace.mjs` (anchor hit 1 →
0, blob restored equal to HEAD). The three S1/S2 pins were committed red
first (`cdd23489`) before the fix (`fe3aea79`), the two S4 pins likewise
(`ef76a598` before `ff3c8f8e`), and the S5 pin too (`82e0cee3` before
`18a95de4`). `scim-connection-service.test.ts` adds unit cases for
`scimCredentialIsLive` (active false, 0, null or absent; no expiry;
past; exactly now; future; unreadable).
## Acceptance notes
- Matching reading: each mapping row names exactly one group key, and
matching is per row (external id when the row has one, else exact
display name).
- Beyond the acceptance rows: saving or deleting a mapping reprojects
current members at once, and the boundary is re-judged at grant time, so
a set later redefined with admin bits grants nothing (warned once).
- Known edge: two provisioning domains that project the same (set,
organization) share one grant row; the marker names the domain that
wrote it.
- No Console surface for mappings in this PR; the two doors are the only
writer.
- Serial with #30967: whichever lands second merges `origin/main` once
and re-runs the plugin-auth and platform-objects suites.
## Verification
Head `d8ef8e69`, after merging `origin/main` four times without rebasing
(`1efd345f`, `ae12be81`, `52aa0fc6`, and `1ccf149e` at `645ed3d1`, which
includes the de bundle from #31511). The first merge resolved
`delegated-admin-gate.ts` by keeping #31384's helpers; the lifted
predicate is still verbatim. The census artefacts were regenerated from
main's copy.
- Build: `turbo run build` 120/120 tasks.
- Typecheck: `pnpm --filter PKG typecheck` (including each package's
`check:test-typecheck`) exit 0 for plugin-security, platform-objects,
plugin-auth, spec and cli; `check:type-check-debt` exit 0.
- Lint: `eslint --no-inline-config` over the touched packages, exit 0.
- Suites on the merged tree: plugin-auth 243 files, 4337 passed / 10
skipped (on `d8ef8e69`); plugin-security 5886 passed / 45 skipped;
platform-objects 1886 passed (on `d8ef8e69`); cli 7582 passed / 9
skipped (unit and integration). The ownership-ceremony files (inventory
unit, `dev-organization-ownership-stale-column`,
`start-organization-ownership-refusal`, `declared-rule-mirrors`,
`no-organization`, `sqlite-wasm`) were 10 red before the inventory row
and are 42/42 green on `42f60764` with cli rebuilt.
- i18n: `check:i18n`, `check:i18n-coverage` (none new),
`check:i18n-stale-fill` and `check:i18n-walk-parity` exit 0 on
`d8ef8e69`; the de bundle keeps only the `id` source-hash entry, like
the other locales. `check-tenant-audit-census`,
`check:platform-object-tenancy-census` and `check:auth-mount-ledger`
exit 0 there too.
- Acceptance file: 14/14 passed; ten ablations red as listed under
Tests.
- Derived gates: `dispatch-gates --commands` derived 130 families on
`95349ede`; all 130 ran there, every one exit 0, and `--ran` reconciled
130 run, 0 NOT-MEASURED, 0 UNRUN. `d8ef8e69` adds only the main merge
and the de bundle; the i18n, census and ledger gates above were re-run
on it. `pnpm --filter @objectstack/plugin-auth typecheck` (with
`check:test-typecheck`) exit 0 on `95349ede`.
- Line budget against `main`: 48 files, +2061 −111. Generated output
(translation bundles in nine locales, two census artefacts and the
tenancy census JSON) is 585 of it; authored is 26 files, +1476 −69.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy)_
<!-- signed: domain:server#6 · os-project-manager ·
session_01FEkQ5p3T5YWhLjurajudnQ -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 133f75a commit ed4b832
48 files changed
Lines changed: 2061 additions & 111 deletions
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages
- cli/src/utils
- platform-objects
- scripts
- src
- apps/translations
- identity
- plugins
- plugin-auth/src
- plugin-security/src
- qa/dogfood/test
- spec/src
- contracts
- system/constants
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
135 | | - | |
| 135 | + | |
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
163 | | - | |
164 | | - | |
| 163 | + | |
| 164 | + | |
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | | - | |
201 | | - | |
202 | | - | |
203 | | - | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
| |||
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | | - | |
| 220 | + | |
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
224 | | - | |
| 224 | + | |
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
237 | | - | |
238 | | - | |
239 | | - | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
240 | 240 | | |
241 | | - | |
242 | | - | |
243 | | - | |
244 | | - | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
245 | 245 | | |
246 | | - | |
| 246 | + | |
247 | 247 | | |
248 | | - | |
| 248 | + | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
252 | | - | |
| 252 | + | |
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | | - | |
258 | | - | |
| 257 | + | |
| 258 | + | |
259 | 259 | | |
260 | | - | |
| 260 | + | |
261 | 261 | | |
262 | 262 | | |
263 | 263 | | |
| |||
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
310 | | - | |
| 310 | + | |
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
315 | 315 | | |
316 | | - | |
| 316 | + | |
317 | 317 | | |
318 | 318 | | |
319 | 319 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
| 97 | + | |
98 | 98 | | |
99 | | - | |
| 99 | + | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| |||
155 | 155 | | |
156 | 156 | | |
157 | 157 | | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
158 | 161 | | |
159 | 162 | | |
160 | 163 | | |
| |||
173 | 176 | | |
174 | 177 | | |
175 | 178 | | |
| 179 | + | |
| 180 | + | |
176 | 181 | | |
177 | 182 | | |
178 | 183 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
784 | 784 | | |
785 | 785 | | |
786 | 786 | | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
787 | 799 | | |
788 | 800 | | |
789 | 801 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| 148 | + | |
148 | 149 | | |
149 | 150 | | |
150 | 151 | | |
| |||
319 | 320 | | |
320 | 321 | | |
321 | 322 | | |
| 323 | + | |
322 | 324 | | |
323 | 325 | | |
324 | 326 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2649 | 2649 | | |
2650 | 2650 | | |
2651 | 2651 | | |
| 2652 | + | |
| 2653 | + | |
| 2654 | + | |
| 2655 | + | |
| 2656 | + | |
| 2657 | + | |
| 2658 | + | |
| 2659 | + | |
| 2660 | + | |
| 2661 | + | |
| 2662 | + | |
| 2663 | + | |
| 2664 | + | |
| 2665 | + | |
| 2666 | + | |
| 2667 | + | |
| 2668 | + | |
| 2669 | + | |
| 2670 | + | |
| 2671 | + | |
| 2672 | + | |
| 2673 | + | |
| 2674 | + | |
| 2675 | + | |
| 2676 | + | |
| 2677 | + | |
| 2678 | + | |
| 2679 | + | |
| 2680 | + | |
| 2681 | + | |
| 2682 | + | |
| 2683 | + | |
| 2684 | + | |
| 2685 | + | |
| 2686 | + | |
| 2687 | + | |
| 2688 | + | |
| 2689 | + | |
| 2690 | + | |
| 2691 | + | |
| 2692 | + | |
| 2693 | + | |
| 2694 | + | |
| 2695 | + | |
| 2696 | + | |
| 2697 | + | |
| 2698 | + | |
| 2699 | + | |
2652 | 2700 | | |
2653 | 2701 | | |
2654 | 2702 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
| 35 | + | |
34 | 36 | | |
35 | 37 | | |
36 | 38 | | |
| |||
0 commit comments