Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2e9c46f
feat(plugin-auth): SCIM group push grants mapped permission sets (WIP)
claude Nov 8, 2026
97d9076
test(plugin-auth): SCIM group mapping acceptance pins, route ledger a…
claude Nov 8, 2026
682f670
test(plugin-auth): the hand-made admin grant carries its organization
claude Nov 8, 2026
b6fd47b
chore(platform-objects): translation bundles for sys_idp_group_mapping
claude Nov 8, 2026
81403c6
fix(plugin-auth): literal group-mapping mounts, typed write receivers…
claude Nov 8, 2026
776f80e
chore: platform-object tenancy census row for sys_idp_group_mapping
claude Nov 8, 2026
1efd345
Merge origin/main into claude/project-thread-dv4dek
claude Nov 8, 2026
cdd2348
test(plugin-auth): pin organization-less connections and the reconcil…
claude Nov 8, 2026
fe3aea7
fix(plugin-auth): refuse an organization-less SCIM connection at save…
claude Nov 8, 2026
0110f58
fix(plugin-auth): the projected grant carries its organization as the…
claude Nov 8, 2026
ae12be8
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 8, 2026
fe826d6
chore(platform-objects): pt-BR labels for sys_idp_group_mapping
claude Nov 8, 2026
d6b9645
test(plugin-auth): read the saved mapping id through a typed helper
claude Nov 8, 2026
20e729e
fix(cli): ADR-0131 D10 inventory row for sys_idp_group_mapping
claude Nov 8, 2026
6244fbd
chore: changeset names the cli inventory row
claude Nov 8, 2026
52aa0fc
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 8, 2026
861c284
chore(platform-objects): ru labels for sys_idp_group_mapping
claude Nov 8, 2026
42f6076
fix(cli): keep the tracker id out of the inventory citation
claude Nov 8, 2026
ef76a59
test(plugin-auth): a revoked SCIM credential does not decide the mapp…
claude Nov 8, 2026
ff3c8f8
fix(plugin-auth): read a SCIM connection's organization from its live…
claude Nov 8, 2026
59e94f9
chore: changeset says only live credentials decide the organization
claude Nov 8, 2026
b13015e
test(plugin-auth): widen the S4 pin's body cast through unknown
claude Nov 8, 2026
82e0cee
test(plugin-auth): a mapping re-save after the connection moved organ…
claude Nov 8, 2026
18a95de
fix(plugin-auth): refuse a mapping re-save whose same-key row lives i…
claude Nov 8, 2026
95349ed
chore: ledger note and changeset name the cross-organization re-save …
claude Nov 8, 2026
1ccf149
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 8, 2026
d8ef8e6
chore(platform-objects): de labels for sys_idp_group_mapping
claude Nov 8, 2026
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
22 changes: 22 additions & 0 deletions .changeset/30959-scim-group-permission-set-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/plugin-security': patch
'@objectstack/platform-objects': patch
'@objectstack/spec': patch
'@objectstack/cli': patch
---

feat(plugin-auth): a SCIM group push grants the ordinary permission set its group mapping names

Clause-②: yes (widening)
Metadata-protocol: none

An identity provider that pushes a group over SCIM now changes what its members may do. A platform administrator saves a mapping from one SCIM connection's group to one permission set with `POST /api/v1/auth/admin/scim/set-group-mapping` (`connectionId`, `groupExternalId` or `groupDisplayName`, `permissionSet`), and deletes one with `POST /api/v1/auth/admin/scim/delete-group-mapping` (`mappingId`). Both run the platform-admin gate the SCIM credential door runs. A connection whose credential names no organization, or whose credentials name two, is refused `400 VALIDATION_FAILED` on `connectionId`, so every mapping, and every grant it makes, belongs to exactly one organization. Only live credentials (active and unexpired, as the bearer verifier reads them) decide that organization: a credential revoked in one organization does not block a mapping once a new one is minted in another. Saving the same group and set again is idempotent; if that mapping was saved while another organization owned the connection, the save is refused `409 RESOURCE_CONFLICT` naming the old mapping, which must be deleted first. The rows live in the new platform object `sys_idp_group_mapping`, which the data API only lists.

When a group's membership changes, `@better-auth/scim`'s projection grants each member the mapped set in the organization that owns the connection, inside the SCIM transaction, written by the connection (`svc:scim:CONNECTION_ID` in the audit trail). Removing a member removes that grant. A group nobody mapped grants nothing. The projection asks the boundary again before every grant it writes, so a set that becomes an administrator set after its mapping was saved grants nothing. A grant the projection did not make, such as one an administrator made by hand, is never removed. Saving or deleting a mapping applies it to the group's current members at once.

A mapping can never make anyone an administrator. The save refuses, with `400 VALIDATION_FAILED` on `permissionSet`, every set outside the organization boundary: `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 delegated-administration predicate, now served as the optional `ISecurityService.organizationBoundaryFailure(setName)` member. Without a security service the save is refused `503` and nothing is granted.

The break-glass guard's refusal no longer tells operators that "the SCIM group mapping is too broad": a group mapping never moves administrator standing, so the sentence now says to grant another account standing first.

The v18 organization-ownership ceremony (`os migrate organization-ownership`) inventories `sys_idp_group_mapping` with the attribution fate, like the other organization-owned rows written with their organization: a database that holds the new table plans and applies as before.
54 changes: 27 additions & 27 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **56 of the 270 sites are spelled that way**. A
forwarding shim cannot, and **57 of the 275 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -160,8 +160,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla

**"No tenant context" counted sites it had not read.** An options argument the
walker could not parse was folded into the same bucket as one it had read and
found empty. That published **64 sites "carrying no tenant context at all"**
when 8 said so and 56 were simply unread — an over-claim in the *alarming*
found empty. That published **67 sites "carrying no tenant context at all"**
when 10 said so and 57 were simply unread — an over-claim in the *alarming*
direction, on the very figure this page tells other cards to cite. `carries` is
now three-valued, and an unreadable argument can never contribute to the
provable count.
Expand Down Expand Up @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **270** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **30** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **192 of 270** decidable, **78** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 150 decidably elevated, 0 decidably not, 112 undecidable |
| 175 write call sites | quoted in the merged changeset | **275** |
| 24 carrying no tenant context | quoted in the merged changeset | **3** provable and tenancy-enabled; **30** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **195 of 275** decidable, **80** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 152 decidably elevated, 0 decidably not, 113 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -211,17 +211,17 @@ at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 62 sites reached
through an erased (`any`) receiver, and the 69 that name their object through a
through an erased (`any`) receiver, and the 71 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
150 of 270 (56%) as decidably elevated, with 112 more whose elevation is a
152 of 275 (55%) as decidably elevated, with 113 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `2 / 270`, and say what it is**: the sites whose options argument was
⇒ **Cite `3 / 275`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **30 further sites** have an options argument this
Expand All @@ -233,31 +233,31 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **270** |
| …whose object name is statically decidable | 192 |
| …whose object name is chosen at run time | 78 |
| …against an object with tenancy ENABLED | 186 |
| write call sites on the application surface | **275** |
| …whose object name is statically decidable | 195 |
| …whose object name is chosen at run time | 80 |
| …against an object with tenancy ENABLED | 189 |
| …against an object that declares tenancy off | 6 |
| threading a tenant context | 206 |
| PROVABLY carrying none (options read, no context key) | **8** |
| …of those, against a decidably tenancy-enabled object | **2** |
| options argument UNREADABLE — may or may not carry one | 56 |
| threading a tenant context | 208 |
| PROVABLY carrying none (options read, no context key) | **10** |
| …of those, against a decidably tenancy-enabled object | **3** |
| options argument UNREADABLE — may or may not carry one | 57 |
| …of those, against a decidably tenancy-enabled object | 30 |
| threading a decidably ELEVATED (`isSystem`) context | 150 |
| threading a decidably ELEVATED (`isSystem`) context | 152 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 112 |
| threading a context whose elevation is a run-time fact | 113 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 208 |
| receiver carried a readable engine type | 213 |
| receiver erased, placed by the object NAME | 52 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 123 |
| object name spelled through a `const` | 69 |
| object name spelled inline | 124 |
| object name spelled through a `const` | 71 |
| object name is an `object: string` parameter | 7 |
| object name is some other run-time expression | 71 |
| object name is some other run-time expression | 73 |

### Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-11-08 at `759f4daa0`.
Measured on 2026-11-08 at `fe3aea79c`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 1060 |
| tracked non-test sources scanned | 1063 |
| engine-shaped types recognised | 114 |
| declared objects in the registry | 131 |
| declared objects in the registry | 132 |
| same-named calls subtracted as non-engine | 243 |

{/* END GENERATED: tenant-audit-census */}
31 changes: 18 additions & 13 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 270 |
| Object name statically decidable | 192 |
| Object name chosen at run time | 78 |
| Against a tenancy-enabled object | 186 |
| Write call sites | 275 |
| Object name statically decidable | 195 |
| Object name chosen at run time | 80 |
| Against a tenancy-enabled object | 189 |
| Against an object declaring tenancy off | 6 |
| Threading a tenant context | 206 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 56 |
| Threading a tenant context | 208 |
| Provably carrying none | 10 |
| …and decidably tenancy-enabled | 3 |
| Options argument unreadable | 57 |
| …and decidably tenancy-enabled | 30 |
| Threading a decidably elevated context | 150 |
| Threading a decidably elevated context | 152 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 112 |
| Threading a context of undecidable elevation | 113 |

## Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-11-08 at `759f4daa0`.
Measured on 2026-11-08 at `fe3aea79c`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 1060 |
| tracked non-test sources scanned | 1063 |
| engine-shaped types recognised | 114 |
| declared objects in the registry | 131 |
| declared objects in the registry | 132 |
| same-named calls subtracted as non-engine | 243 |

## Every site
Expand Down Expand Up @@ -155,6 +155,9 @@ Measured on 2026-11-08 at `759f4daa0`.
| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `update` | `sys_member` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.API_KEY` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts` | `delete` | `IDP_GROUP_MAPPING_OBJECT` | undecidable | PROVABLY NONE | 1 |
| `packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts` | `insert` | `IDP_GROUP_MAPPING_OBJECT` | undecidable | options unreadable | 1 |
| `packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/idp-locale-claim.ts` | `update` | `sys_user` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/mcp-client-last-use.ts` | `update` | `sys_oauth_consent` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 |
Expand All @@ -173,6 +176,8 @@ Measured on 2026-11-08 at `759f4daa0`.
| `packages/plugins/plugin-auth/src/scim-connection-admin.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `update` | `sys_scim_connection_credential` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/scim-group-projection.ts` | `delete` | `sys_user_permission_set` | enabled | PROVABLY NONE | 1 |
| `packages/plugins/plugin-auth/src/scim-group-projection.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/sys-session-revoke.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 |
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,18 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
`${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: a declarative connector's stored credential is environment-level, ` +
'read by the reference its instances name, never by organization',
},
// #30959 (ruling record 6718345088, fork 1 (a)) — an identity-provider group
// mapping is saved by the platform administrator for one SCIM connection and
// always carries the organization that owns the connection's credential (the
// save refuses a connection whose credential names none), so a NULL row has
// no producer.
{
object: 'sys_idp_group_mapping',
census: PMAIN,
fate: 'attribution',
citation: `${CENSUS_MAIN} (in reach, added since the first census; one row per SCIM connection, in the organization that owns it); ${D10_SINGLE}`,
anchors: [],
},
// ADR-0143 D3 — a connector connection is made by an organization's
// administrator and dispatched with by that organization's runs (Q1 = A):
// every row is written with the organization that connected it, so a NULL
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ import {
SysScimProjectionGrant,
SysScimSubject,
SysScimUser,
SysIdpGroupMapping,
} from '../src/identity/index.js';

// ── Security ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -319,6 +320,7 @@ const config: ObjectStackDefinition = defineStack({
SysScimProjectionGrant,
SysScimSubject,
SysScimUser,
SysIdpGroupMapping,

// Security: RBAC moved to @objectstack/plugin-security, sharing to
// @objectstack/plugin-sharing (ADR-0029 K2 / D8).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2649,6 +2649,54 @@ export const arObjects: NonNullable<TranslationData['objects']> = {
}
}
},
sys_idp_group_mapping: {
label: "ربط مجموعة IdP",
pluralLabel: "روابط مجموعات IdP",
description: "يربط مجموعة من مزوّد الهوية بمجموعة الأذونات الممنوحة لأعضائها",
fields: {
id: {
label: "ID"
},
source_kind: {
label: "نوع المصدر",
help: "ما يرسل المجموعة: اتصال SCIM.",
options: {
scim: "اتصال SCIM"
}
},
source_id: {
label: "معرّف المصدر",
help: "معرّف اتصال SCIM الذي تصل عبره المجموعة، مثل \"okta-prod\"."
},
group_external_id: {
label: "المعرّف الخارجي للمجموعة",
help: "قيمة externalId للمجموعة كما يرسلها مزوّد الهوية. اضبط هذا الحقل أو اسم العرض، وليس كليهما."
},
group_display_name: {
label: "اسم عرض المجموعة",
help: "قيمة displayName للمجموعة، بمطابقة تامة. يُستخدم عندما لا يحدد الربط معرّفًا خارجيًا."
},
permission_set: {
label: "مجموعة الأذونات",
help: "مجموعة الأذونات الممنوحة لعضو المجموعة. تُرفض مجموعات المسؤول."
},
organization_id: {
label: "المؤسسة",
help: "المؤسسة التي يتم المنح فيها: المؤسسة المالكة لاتصال SCIM."
},
created_at: {
label: "تاريخ الإنشاء"
},
updated_at: {
label: "تاريخ التحديث"
}
},
_views: {
all: {
label: "الكل"
}
}
},
sys_notification: {
label: "حدث الإشعار",
pluralLabel: "أحداث الإشعارات",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const arGeneratedSourceHashes: Readonly<Record<string, string>> = {
"metadataForms.permission.fields.tabPermissions.helpText": "8942e2aebe59897c",
"metadataForms.position.fields.name.helpText": "3594bba7cc2a3c8a",
"objects.sys_email.fields.message_id.label": "14cd089a4062f4b1",
"objects.sys_idp_group_mapping.fields.id.label": "00b0385c9c152888",
"objects.sys_job.fields.schedule_type.options.cron": "fd48d0e2cd59cab2",
"objects.sys_oauth_application.fields.jwks.label": "b2cf79f9d66c0dbc",
"objects.sys_oauth_application.fields.jwks_uri.label": "2a4339df734ab269",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const OWNED_OBJECTS = new Set([
'sys_scim_connection_binding', 'sys_scim_connection_credential', 'sys_scim_group',
'sys_scim_group_member', 'sys_scim_identity_tombstone', 'sys_scim_projection_grant',
'sys_scim_subject', 'sys_scim_user',
// identity — IdP group → permission set mapping (#30959; SCIM now, SSO group claims later)
'sys_idp_group_mapping',
// audit / messaging-adjacent (still owned here)
'sys_notification', 'sys_attachment', 'sys_email', 'sys_email_template',
// (sys_saved_report / sys_report_schedule retired with the saved-report stack, #20102)
Expand Down
Loading
Loading