Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/30967-sso-group-permission-set-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/platform-objects': patch
---

feat(plugin-auth): an SSO sign-in grants the ordinary permission set its IdP group is mapped to

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

An enterprise SSO provider (OIDC or SAML, through `@better-auth/sso`) can now drive what its users may do from the groups the identity provider sends at sign-in, through the same `sys_idp_group_mapping` mappings a SCIM connection uses. There is no second mapping model.

- **Where the groups come from.** Set the provider's own claim mapping to fill `groups`: `mapping.extraFields: { groups: 'groups' }` for an OIDC claim named `groups`, or the attribute name an IdP uses for SAML (for example Entra ID's groups claim URI). The value may be one string or a list of strings. A provider whose mapping fills no `groups` behaves as before.
- **Saving a mapping.** `POST /api/v1/auth/admin/scim/set-group-mapping` now also takes `providerId` in place of `connectionId` (exactly one of the two; both is `400 VALIDATION_FAILED` on `providerId`). The grant's organization is the provider's own `organization_id`; a provider that names none, or an unknown provider, is refused `400 VALIDATION_FAILED` on `providerId`. With SSO off the save is refused `503`. The same platform-admin gate and the same organization-boundary judgement apply: administrator sets and every set outside the organization boundary are refused, and without a security service the save is refused `503`. The row is stored with `source_kind: 'sso'` (a new option on `sys_idp_group_mapping.source_kind`) and `source_id` = the provider id.
- **At each sign-in.** Every sign-in through the provider matches each group value against the provider's mappings (by `groupExternalId` when the mapping names one, which suits Entra ID's group object ids, else by exact `groupDisplayName`) and makes the user's grants follow them: a mapped group grants its set in the provider's organization, and a group the user has left loses the set at their next sign-in. A sign-in with no groups removes the grants that provider made earlier. The boundary is asked again before every grant, so a set that becomes an administrator set after its mapping was saved grants nothing, and with no security service nothing is granted. Only grants the provider made (their `reason` names the provider) are ever removed: a grant made by hand, or by SCIM, is never touched. Every write goes through the engine, so the break-glass last-administrator guard still refuses what it refuses elsewhere. If applying the mappings fails, the sign-in still succeeds and the failure is logged at `error`.
- **Deleting a mapping.** `POST /api/v1/auth/admin/scim/delete-group-mapping` removes an SSO mapping and, at once, the grants its provider made of that set in that organization, unless another of the provider's mappings still names that set (then each user's next sign-in decides).
34 changes: 17 additions & 17 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 **57 of the 275 sites are spelled that way**. A
forwarding shim cannot, and **57 of the 276 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 @@ -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 | **275** |
| 175 write call sites | quoted in the merged changeset | **276** |
| 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 |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **196 of 276** decidable, **80** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 153 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 71 that name their object through a
through an erased (`any`) receiver, and the 72 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
152 of 275 (55%) as decidably elevated, with 113 more whose elevation is a
153 of 276 (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 `3 / 275`, and say what it is**: the sites whose options argument was
⇒ **Cite `3 / 276`, 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,29 +233,29 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **275** |
| …whose object name is statically decidable | 195 |
| write call sites on the application surface | **276** |
| …whose object name is statically decidable | 196 |
| …whose object name is chosen at run time | 80 |
| …against an object with tenancy ENABLED | 189 |
| …against an object with tenancy ENABLED | 190 |
| …against an object that declares tenancy off | 6 |
| threading a tenant context | 208 |
| threading a tenant context | 209 |
| 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 | 152 |
| threading a decidably ELEVATED (`isSystem`) context | 153 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 113 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 213 |
| receiver carried a readable engine type | 214 |
| 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 | 124 |
| object name spelled through a `const` | 71 |
| object name spelled through a `const` | 72 |
| object name is an `object: string` parameter | 7 |
| object name is some other run-time expression | 73 |

Expand Down Expand Up @@ -307,12 +307,12 @@ 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 `fe3aea79c`.
Measured on 2026-11-08 at `da13e5987`.

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

Expand Down
17 changes: 9 additions & 8 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,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 275 |
| Object name statically decidable | 195 |
| Write call sites | 276 |
| Object name statically decidable | 196 |
| Object name chosen at run time | 80 |
| Against a tenancy-enabled object | 189 |
| Against a tenancy-enabled object | 190 |
| Against an object declaring tenancy off | 6 |
| Threading a tenant context | 208 |
| Threading a tenant context | 209 |
| Provably carrying none | 10 |
| …and decidably tenancy-enabled | 3 |
| Options argument unreadable | 57 |
| …and decidably tenancy-enabled | 30 |
| Threading a decidably elevated context | 152 |
| Threading a decidably elevated context | 153 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 113 |

Expand Down Expand Up @@ -90,12 +90,12 @@ 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 `fe3aea79c`.
Measured on 2026-11-08 at `da13e5987`.

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

Expand Down Expand Up @@ -158,6 +158,7 @@ Measured on 2026-11-08 at `fe3aea79c`.
| `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-group-mapping-admin.ts` | `delete` | `sys_user_permission_set` | 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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -2659,14 +2659,15 @@ export const arObjects: NonNullable<TranslationData['objects']> = {
},
source_kind: {
label: "نوع المصدر",
help: "ما يرسل المجموعة: اتصال SCIM.",
help: "ما يرسل المجموعة: اتصال SCIM، أو مزوّد SSO عند تسجيل الدخول.",
options: {
scim: "اتصال SCIM"
scim: "اتصال SCIM",
sso: "مزوّد SSO"
}
},
source_id: {
label: "معرّف المصدر",
help: "معرّف اتصال SCIM الذي تصل عبره المجموعة، مثل \"okta-prod\"."
help: "معرّف اتصال SCIM أو معرّف مزوّد SSO الذي تصل عبره المجموعة، مثل \"okta-prod\"."
},
group_external_id: {
label: "المعرّف الخارجي للمجموعة",
Expand All @@ -2682,7 +2683,7 @@ export const arObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "المؤسسة",
help: "المؤسسة التي يتم المنح فيها: المؤسسة المالكة لاتصال SCIM."
help: "المؤسسة التي يتم المنح فيها: المؤسسة المالكة لاتصال SCIM أو لمزوّد SSO."
},
created_at: {
label: "تاريخ الإنشاء"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2659,14 +2659,15 @@ export const deObjects: NonNullable<TranslationData['objects']> = {
},
source_kind: {
label: "Quellart",
help: "Was die Gruppe überträgt: eine SCIM-Verbindung.",
help: "Was die Gruppe überträgt: eine SCIM-Verbindung oder ein SSO-Anbieter bei der Anmeldung.",
options: {
scim: "SCIM-Verbindung"
scim: "SCIM-Verbindung",
sso: "SSO-Anbieter"
}
},
source_id: {
label: "Quell-ID",
help: "Die ID der SCIM-Verbindung, über die die Gruppe eintrifft, z. B. \"okta-prod\"."
help: "Die ID der SCIM-Verbindung oder des SSO-Anbieters, über die die Gruppe eintrifft, z. B. \"okta-prod\"."
},
group_external_id: {
label: "Externe Gruppen-ID",
Expand All @@ -2682,7 +2683,7 @@ export const deObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organisation",
help: "Die Organisation, in der die Berechtigung erteilt wird: die Organisation, der die SCIM-Verbindung gehört."
help: "Die Organisation, in der die Berechtigung erteilt wird: die Organisation, der die SCIM-Verbindung oder der SSO-Anbieter gehört."
},
created_at: {
label: "Erstellt am"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2659,14 +2659,15 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
source_kind: {
label: "Source Kind",
help: "What pushes the group: a SCIM connection.",
help: "What sends the group: a SCIM connection, or an SSO provider at sign-in.",
options: {
scim: "SCIM Connection"
scim: "SCIM Connection",
sso: "SSO Provider"
}
},
source_id: {
label: "Source ID",
help: "The SCIM connection ID the group arrives through, e.g. \"okta-prod\"."
help: "The SCIM connection ID or the SSO provider ID the group arrives through, e.g. \"okta-prod\"."
},
group_external_id: {
label: "Group External ID",
Expand All @@ -2682,7 +2683,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organization",
help: "The organization the grant is made in: the organization that owns the SCIM connection."
help: "The organization the grant is made in: the organization that owns the SCIM connection or the SSO provider."
},
created_at: {
label: "Created At"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2659,14 +2659,15 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
source_kind: {
label: "Tipo de origen",
help: "Qué envía el grupo: una conexión SCIM.",
help: "Qué envía el grupo: una conexión SCIM o un proveedor SSO al iniciar sesión.",
options: {
scim: "Conexión SCIM"
scim: "Conexión SCIM",
sso: "Proveedor SSO"
}
},
source_id: {
label: "ID de origen",
help: "El ID de la conexión SCIM por la que llega el grupo, p. ej. \"okta-prod\"."
help: "El ID de la conexión SCIM o del proveedor SSO por el que llega el grupo, p. ej. \"okta-prod\"."
},
group_external_id: {
label: "ID externo del grupo",
Expand All @@ -2682,7 +2683,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organización",
help: "La organización en la que se concede: la organización propietaria de la conexión SCIM."
help: "La organización en la que se concede: la organización propietaria de la conexión SCIM o del proveedor SSO."
},
created_at: {
label: "Creado el"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2659,14 +2659,15 @@ export const frObjects: NonNullable<TranslationData['objects']> = {
},
source_kind: {
label: "Type de source",
help: "Ce qui envoie le groupe : une connexion SCIM.",
help: "Ce qui envoie le groupe : une connexion SCIM, ou un fournisseur SSO à la connexion.",
options: {
scim: "Connexion SCIM"
scim: "Connexion SCIM",
sso: "Fournisseur SSO"
}
},
source_id: {
label: "ID de source",
help: "L’ID de la connexion SCIM par laquelle le groupe arrive, p. ex. \"okta-prod\"."
help: "L’ID de la connexion SCIM ou du fournisseur SSO par lequel le groupe arrive, p. ex. \"okta-prod\"."
},
group_external_id: {
label: "ID externe du groupe",
Expand All @@ -2682,7 +2683,7 @@ export const frObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organisation",
help: "L’organisation dans laquelle l’attribution est faite : celle qui possède la connexion SCIM."
help: "L’organisation dans laquelle l’attribution est faite : celle qui possède la connexion SCIM ou le fournisseur SSO."
},
created_at: {
label: "Créé le"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2659,14 +2659,15 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
source_kind: {
label: "ソース種別",
help: "グループを送信するもの:SCIM 接続。",
help: "グループを送信するもの:SCIM 接続、またはサインイン時の SSO プロバイダー。",
options: {
scim: "SCIM 接続"
scim: "SCIM 接続",
sso: "SSO プロバイダー"
}
},
source_id: {
label: "ソース ID",
help: "グループが届く SCIM 接続の ID(例:\"okta-prod\")。"
help: "グループが届く SCIM 接続の ID または SSO プロバイダーの ID(例:\"okta-prod\")。"
},
group_external_id: {
label: "グループ外部 ID",
Expand All @@ -2682,7 +2683,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "組織",
help: "付与が行われる組織:SCIM 接続を所有する組織。"
help: "付与が行われる組織:SCIM 接続または SSO プロバイダーを所有する組織。"
},
created_at: {
label: "作成日時"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2659,14 +2659,15 @@ export const ptBRObjects: NonNullable<TranslationData['objects']> = {
},
source_kind: {
label: "Tipo de origem",
help: "O que envia o grupo: uma conexão SCIM.",
help: "O que envia o grupo: uma conexão SCIM ou um provedor SSO no login.",
options: {
scim: "Conexão SCIM"
scim: "Conexão SCIM",
sso: "Provedor SSO"
}
},
source_id: {
label: "ID de origem",
help: "O ID da conexão SCIM pela qual o grupo chega, por exemplo \"okta-prod\"."
help: "O ID da conexão SCIM ou do provedor SSO pelo qual o grupo chega, por exemplo \"okta-prod\"."
},
group_external_id: {
label: "ID externo do grupo",
Expand All @@ -2682,7 +2683,7 @@ export const ptBRObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organização",
help: "A organização em que a concessão é feita: a organização proprietária da conexão SCIM."
help: "A organização em que a concessão é feita: a organização proprietária da conexão SCIM ou do provedor SSO."
},
created_at: {
label: "Criado em"
Expand Down
Loading
Loading