Skip to content

M1-42 Groups from OIDC claims

No Date Ruling Reason
M1-42/1 2026-10-14 Base int/m1-wave14 at fd504d9, branch feat/M1-42-identity-oidc-groups in its own worktree; the branch is not merged locally, the integration lane ports it. Text this ticket adds is English, also in the German chapters and docs/operations/oidc-entra.md. Instructions of the launcher, which override the brief's merge step and language (as M1-45/1).
M1-42/2 2026-10-14 Preconditions verified: group.member.sync is in actions.yaml (resource user, right system, audit, rights change, no rate-limit class), in 05 rule 1.6 and in 02 4.6 for authz; M1-41 carries the external identity in auth_login_flows and maps in completeOidcLogin; M1-29 provides the rights hook with permission_version, access.changed, VerifyInvariants and the deny entry in its own transaction. Brief "Preconditions".
M1-42/3 2026-10-14 The port types use ids.ID instead of uuid.UUID, and ReconcileClaimMemberships takes the provider id as first parameter and MembershipWithGroup/Group as inputs; ClaimDiff carries ExpiresAt. SyncClaimGroups is declared in the interface authz.ClaimSync, which Admin embeds, so identity depends on that one method. Every id of the code base is ids.ID; without the provider the pure function cannot keep claim groups of other providers untouched, which its table test asks.
M1-42/4 2026-10-14 The unit of work of completeOidcLogin runs first without a rights change. When the sync finds a change it returns authz.ErrClaimSyncNeedsRightsChange without writing; identity rolls the unit back and runs it once more declared as a rights change of group.member.sync (resource kind user, the id is filled in by authz). A login with a present claim but nothing to change therefore never raises permission_version; a login that changes pays a second attempt. 03 section 6.3 requires permission_version to be locked before any domain row of a rights change, and 08 section 3.4 item 3 forbids raising it without a change; the person is only known after the flow row is locked. AllowLateRightsChange is reserved for the Idempotency-Key stage.
M1-42/5 2026-10-14 The sync leaves a per-unit state that the rights check reads: access.changed and group.changed name the person as actor (the unit's request is anonymous), and the deny entry of an I1 violation names the person as actor and resource and adds provider_id, group_ids (every group of the change) and agent_ids to the violations; the error carries the hint "Sign-in blocked by a group mapping. An owner must switch the affected agent to organization first." (HintI1ClaimSync). Brief "Transaction" and "I1 violation": actor = the person; the generic deny entry of M1-29 names the declared resource, which has no id before the person is known.
M1-42/6 2026-10-14 Extending a membership clears expiry_reminded_at (brief) and also expiry_reported_at; 03 now says the report is written once per expiry time. Every rights change folds due expiries first (M1-30), so an expired claim membership is reported in the same unit and then extended; with the marker kept, its next expiry would never be reported and the client would keep a stale view.
M1-42/7 2026-10-14 An I1 violation ends the flow as failed (own unit after the rollback) and answers the InvariantError; it does not count a failed attempt of the enrollment code and writes no deny entry of auth.login.complete (the deny entry of group.member.sync is the record). Brief "I1 violation": the enrollment code stays unused, and one deny entry names what happened.
M1-42/8 2026-10-14 groups_state is written only for a provider with groups_claim; groups only with present (sorted, without duplicates, empty omitted); ParseExternalIdentity refuses other combinations. A JSON null claim is a claim of another form (login fails); _claim_names that is not an object is ignored. Fail closed for a claim the provider sends malformed; absent keeps the old memberships until they run out.
M1-42/9 2026-10-14 Claim values never reach logs or audit details; group.changed carries the Group of the contract, whose external_ref is the owner's mapping, so TestGroupClaimSync_ValuesNeverLogged checks logs and audit for every value and events only for a value without a group. The GroupChangedPayload schema of M1-48 embeds Group with external_ref, which group.read shows anyway; changing the contract is out of scope.
M1-42/10 2026-10-14 Removing an expired claim membership whose value left the claim is a change (sets removed_at, member_removed, rights version raised). The row is not removed yet (07 section 2.5 keeps expiry and removal apart); leaving it would let a later claim extend it after a removal the claim already asked for.
M1-42/11 2026-10-14 Tests: the brief's integration tests run through the login in identity/adapters/httpapi (one world per group, as M1-41/13); TestGroupClaimSync_OtherProviderUntouched, the concurrency of the store path, TestGroupClaimSync_ExpiryWithoutJob and the AZ-P7 case of group.member.sync run in the authz PostgreSQL world, which has the Authorizer with session fakes. The red runs: the new domain and parsing tests failed to build before the code; the app and login tests were written with the use case and were shown red by mutation (hook state ignored, callback not reading the claim). The AZ-P7 table of M1-29 lives in authz; the expiry check needs a session that outlives auth.group_claim_max_age, which the identity world's auth.session_max_age does not.
M1-42/12 2026-10-15 Port onto main: code and tests are taken byte for byte from the integration squash; no integration fix of int/m1-wave15 belongs to this ticket (INT-W15/1 to /5 concern M1-49, M1-52 and M2-39), and the ticket adds no migration. The code comments of domain/claimsync.go, the chapter of auth.group_claim_max_age in the schema test and config-schema.json name 08-identity.md; the additions to 03-data-model.md (group_memberships) and docs/operations/oidc-entra.md (new section 7, the configuration values become section 8) are written into the English text at the same places, and the module README section stands after "OIDC login (M1-41)". The operations guide names the manual change of a claim group 409 conflict (reason claim_managed_group), as the problem code check of main requires. Generated files are regenerated, not merged. main is English; no behaviour changes.