diff --git a/dev-docs/.vitepress/config.ts b/dev-docs/.vitepress/config.ts index c9defcb5..302373d2 100644 --- a/dev-docs/.vitepress/config.ts +++ b/dev-docs/.vitepress/config.ts @@ -82,6 +82,7 @@ export default withMermaid(defineConfig({ { text: 'Realm Backup / Restore / DR', link: '/future-features/realm-backup-restore' }, { text: 'Enterprise SSO — SAML + LDAP', link: '/future-features/enterprise-sso-saml-ldap' }, { text: 'SAML federation — implementation plan', link: '/future-features/saml-federation' }, + { text: 'SAML AMR → amr wiring (deferred I15)', link: '/future-features/saml-amr-wiring' }, { text: 'Multi-IdP login UX', link: '/future-features/multi-idp-login-ux' }, { text: 'Login-Providers UI refactor (single-modal)', link: '/future-features/login-providers-ui-refactor' }, { text: 'White-label customization (Phase 2)', link: '/future-features/white-label-customization' }, diff --git a/dev-docs/future-features/index.md b/dev-docs/future-features/index.md index e4c64ffb..fdb6aa9a 100644 --- a/dev-docs/future-features/index.md +++ b/dev-docs/future-features/index.md @@ -39,6 +39,11 @@ zuerst lesen. Customer-IdP). Lib-Wahl: ITfoxtec.Identity.Saml2. Status: Decisions captured 2026-05-27, in active development on `feat/saml-federation`. +- [SAML AMR → `amr` wiring](./saml-amr-wiring) — `SamlFlavorData.AmrMapping` + is configured/seeded but parsed-but-not-consumed (federation v1 deferral + I15). Captures what the read-side wiring would do and why deferring is + safe (fail-closed, additive). Pick up when SAML federated-MFA / step-up + awareness is needed. - [Multi-IdP login UX](./multi-idp-login-ux) — Picker vs Email-Routing vs Hybrid für die Login-Page wenn ein Realm viele Provider hat. Provider-protocol-agnostic, gilt für OIDC + SAML + alles was diff --git a/dev-docs/future-features/saml-amr-wiring.md b/dev-docs/future-features/saml-amr-wiring.md new file mode 100644 index 00000000..10b99858 --- /dev/null +++ b/dev-docs/future-features/saml-amr-wiring.md @@ -0,0 +1,28 @@ +# SAML AMR → `amr` wiring + +**Status:** Deferred (federation v1, decision I15). Parsed-but-not-consumed today; no functional gap for v1. + +**Why:** SAML providers express the strength/method of an authentication via `AuthnContextClassRef` URIs in the assertion (e.g. `urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport`, or vendor MFA refs). The OIDC equivalent is the `amr` claim. Modgud already preserves OIDC `amr` from the external ticket onto the session principal (`ExternalLoginProcessor.Success` copies `amr` → `modgud.external.amr`, consumed by the `TwoFactorFederated` detection). The SAML side has the configuration surface for the same mapping — `SamlFlavorData.AmrMapping` (a `Dictionary`), seeded by the EntraID and ADFS flavor presets — but **no code reads it yet**. The SAML login flow does not currently translate `AuthnContextClassRef` through `AmrMapping` and stamp the resulting `amr` onto the principal. + +This was deferred during the federation v1 build because it is orthogonal to the A–G membership/authorization model that wave delivered, and there is no v1 consumer that depends on SAML-derived `amr` (federated-MFA detection works for OIDC; SAML logins simply carry no `amr`). + +## Current state + +- `SamlFlavorData.AmrMapping` — defined, validated, JSON round-tripped, and seeded by `EntraIdSamlFlavor` / `AdfsSamlFlavor`. Covered by `SamlFlavorDataTests` / `SamlFlavorTests`. +- **No reader.** The SAML assertion's `AuthnContextClassRef` is not looked up against this map; nothing appends `modgud.external.amr` on a SAML login. +- The field carries an XML-doc note pointing here (`SamlFlavorData.AmrMapping`). + +## What the wiring would do + +1. In the SAML login flow (`SamlLoginFlow` / `BuildExternalPrincipal`), read the assertion's `AuthnContextClassRef` value(s). +2. Look each up in the provider's `AmrMapping`; collect the mapped `amr` values. +3. Add them as `amr` claims on the external `ClaimsPrincipal` so they flow through the single shared seam (`ExternalLoginProcessor.Success`) exactly like the OIDC `amr` values — no new consumer code, the existing `TwoFactorFederated` path picks them up. + +## Why it is safe to defer + +- Fail-closed: absent `amr` means "no asserted MFA method", which is the conservative default for `TwoFactorFederated` detection. +- Additive: the config + presets already exist, so enabling it later is a read-side change only — no migration, no schema change, no breaking the persisted flavor data. + +## When to pick it up + +When a customer needs step-up / MFA-state awareness from a SAML IdP (EntraID, ADFS), or when the OIDC and SAML federated-MFA behaviors must be at parity. Pair it with any broader `amr`/ACR work on the OIDC side so both protocols share one normalization table. diff --git a/docs/concepts/auto-membership.md b/docs/concepts/auto-membership.md index 0a066a39..b4336a4a 100644 --- a/docs/concepts/auto-membership.md +++ b/docs/concepts/auto-membership.md @@ -1,7 +1,6 @@ # Auto-Membership -A group is either `Manual` (admin maintains `MemberIds` directly) or -`Auto` (a membership script determines the members dynamically). +A group is either `Manual` (an admin maintains `MemberIds` directly) or `Auto` (a membership script decides the members dynamically). Orthogonally, an `Auto` group may also be marked **`ExternallyDrivable`**, which lets a federated login confer membership *for that session only* — see [Externally-driven membership](#externally-driven-membership-federation) below. ## Manual mode @@ -11,84 +10,107 @@ Group "Backend Team" MemberIds: [, , ] ``` -Admins add and remove users via the UI. Nothing happens -automatically. +Admins add and remove members via the UI. Nothing happens automatically. ## Auto mode ``` -Group "Sales Department" +Group "Active Staff" MembershipMode: Auto - MembershipScript: (p) => p.OrganizationalUnit === "sales" && p.IsActive + MembershipScript: (p) => Type.Is(p, 'person') && p.IsActive MemberIds: [] ``` -`MemberIds` is maintained by the system, not the admin. On every -relevant event (user created/updated/deleted) the script is -re-evaluated. +`MemberIds` is maintained by the system, not the admin. On every relevant event (user created / updated / deleted) the script is re-evaluated. ## Membership script -A TypeScript arrow function from a principal record to `boolean`: +A TypeScript arrow function from a principal record to `boolean`. It is evaluated against each candidate principal; returning `true` means "is a member". ```typescript -// Predicate form -(p) => p.OrganizationalUnit === "engineering" - && p.AccountName !== "service-account-bot" +(p) => Type.Is(p, 'person') && p.IsActive + && p.Email != null + && p.Email.endsWith('@acme.com') + && p.AccountName !== 'svc-bot' +``` + +`Type.Is(p, 'person')` is the type guard — it narrows `p` to a person principal (the same predicate works on groups and service accounts, so guard first). The fields a script can read on a person are the persisted `Person` columns: + +| Field | Type | Notes | +|---|---|---| +| `p.IsActive` / `p.IsDeleted` | `boolean` | lifecycle flags | +| `p.AccountName` | `string?` | login name | +| `p.Firstname` / `p.Lastname` | `string?` | display name parts | +| `p.Acronym` | `string?` | short initials | +| `p.Email` | `string?` | primary email | +| `p.NormalizedUserName` / `p.NormalizedEmail` | `string?` | upper-cased, for case-insensitive compares | + +> These are the **only** durable fields. There is no `OrganizationalUnit`, `Department`, or generic `externalClaims` dictionary on a person — scripts that reference them either fail to transpile or silently never match. To drive membership from an upstream IdP's groups, use an `ExternallyDrivable` group and `p.ExternalGroups` (below). + +The membership-script editor's IntelliSense is generated from the real CLR types, so the available members are always in sync — use it to discover the surface. + +### How it runs (the batch engine) + +`Cocoar.JsEval.Linq` translates the predicate into an `Expression>` → SQL against `mt_doc_principal WHERE mt_doc_type = 'person'`. A single query returns the new `MemberIds`. This is the durable path: it writes `MemberIds` and only ever sees the persisted `Person` fields (it cannot see the ephemeral federation surface, which has no SQL columns). + +## Externally-driven membership (federation) + +An `Auto` group can additionally be marked **`ExternallyDrivable`**. Such a group is **skipped by the batch engine** (it never writes durable `MemberIds`) and is instead evaluated **in memory, at login time**, by the federation deriver — but only when the login arrives through a provider the realm admin marked `TrustForAuthorization`. The match is **session-scoped**: it lives on the sign-in only, is unioned into the access decision while the session/grant is valid, and disappears when the session ends. The session is the lease — nothing is persisted, and the upstream group names never leave Modgud (they are expanded into Modgud roles/permissions before any token or UserInfo response, the hub boundary). + +On top of the durable `Person` fields, an `ExternallyDrivable` script may read the ephemeral federation surface: + +| Field | Type | Notes | +|---|---|---| +| `p.ExternalGroups` | `string[]` | the current provider's `groups` claim for this login (always an array — use `.includes(...)`) | +| `p.Source` | `string` | the source tag of this login: `"local"` or `"provider:"` | + +```typescript +// "Place this login into the group if the upstream IdP put them in 'entra-admins'." +(p) => Type.Is(p, 'person') + && p.IsActive + && p.ExternalGroups.includes('entra-admins') -// With externalClaims (from the most recent OIDC login): -(p) => p.externalClaims.department === "Finance" +// Scope a rule to one provider via p.Source (v1 has no declarative per-provider +// binding yet — the script scopes itself): +(p) => p.Source === 'provider:acme-entra' + && p.ExternalGroups.includes('finance') ``` -Translated by Cocoar.JsEval.Linq into an -`Expression>` → SQL against -`mt_doc_principal WHERE mt_doc_type = 'person'`. A single query -returns the new `MemberIds`. +Key rules: -## Recompute triggers +- **Live-only.** `p.ExternalGroups` reflects *this* login's provider only — a password (local) login carries an empty array, so it never picks up a previously-seen IdP's groups (no stale-admin trap). +- **Never durable.** A match here is never written to `MemberIds`; it exists only for the session. +- **`realm:admin` is local-only.** A group whose roles confer `realm:admin` **cannot** be marked `ExternallyDrivable` (the editor blocks it and the API rejects it), and even an inherited ancestor that confers `realm:admin` is stripped from a session-sourced grant. Manage realm-admin membership manually. `:admin` and below may be externally driven. -`AutoMembershipSyncHandlers` (Wolverine handlers) listen for person -mutation events: +## Recompute triggers (durable groups) + +The durable engine listens for person-mutation events and re-evaluates the affected `Auto` (non-`ExternallyDrivable`) groups: | Event | Action | |---|---| -| `UserCreated` | Check auto-groups whose script predicate matches → on match: add user | -| `UserUpdated` | Check auto-groups → add or remove user based on the new state | -| `UserDeleted` | Remove user from all auto-groups | -| `GroupMembershipScriptChanged` | Full recompute pass for that one group | +| user created | check auto-groups whose predicate matches → on match: add | +| user updated | re-check auto-groups → add or remove based on the new state | +| user deleted | remove from all auto-groups | +| membership script changed | full recompute pass for that one group | ## Dependency tracking (selective recompute) -Auto-membership recompute is expensive if you do it on every -heartbeat update of the user. Solution: per script, when saving, a -**dependency set** of the read properties is calculated: +Recomputing every auto-group on every heartbeat update would be wasteful. So per script, the **set of read properties** is recorded when it is saved: ```typescript // Script -(p) => p.OrganizationalUnit === "sales" && p.IsActive +(p) => p.IsActive && p.Email != null && p.Email.endsWith('@acme.com') // Dependencies -["OrganizationalUnit", "IsActive"] +["IsActive", "Email"] ``` -On `UserUpdated`, we check whether any field in the dependency set -changed. If not → skip the recompute for that group. - -Example: a user updates `LastLoginAt`. `IsActive` and -`OrganizationalUnit` are unchanged → the Sales group isn't checked at -all, even though the `UserUpdated` event fired. +On a user update, only groups whose dependency set intersects the changed fields are re-checked. Example: a user updates `LastLoginAt` (not a person field a script reads) → `IsActive`/`Email` unchanged → the group above is not re-evaluated even though the update event fired. ## Failure handling -If the script throws (translator error or runtime error during -compile), a `GroupMembershipRecomputeFailedEvent` with the error -message is fired. The Group projection sets `MembershipLastError` and -keeps the previous `MemberIds`. The admin sees the error in the group -detail view. - -A successful recompute → `GroupMembershipRecomputedEvent` with the -new `MemberIds`. `MembershipLastError` is set to `null`. +If the script throws (a translator error, or a runtime error during compile), the recompute fails closed: the Group projection records the error in `MembershipLastError` and keeps the previous `MemberIds`. The admin sees the error in the group detail view. A successful recompute clears `MembershipLastError` and writes the new `MemberIds`. ## Nested auto-groups @@ -99,57 +121,39 @@ An auto-group can have another group (manual or auto) as a member: Members: ["Engineering", "Sales", "Support"] ← three auto-groups "Engineering" (Auto) - Script: (p) => p.OrganizationalUnit === "engineering" - -"Sales" (Auto) - Script: (p) => p.OrganizationalUnit === "sales" + Script: (p) => Type.Is(p, 'person') && p.AccountName != null && p.AccountName.startsWith('eng-') ``` -The permission BFS expands this without special-casing — -`IPrincipalWithMembers` is polymorphic. Cycle detection via a visited -set. +The permission BFS expands this without special-casing — `IPrincipalWithMembers` is polymorphic, with cycle detection via a visited set. Session-derived membership inherits the same way: a session-matched child group still confers its parent groups' roles for that session. ## Initial recompute -When an admin creates a new auto-group (or changes the script), an -initial full pass runs: - -```csharp -// IAutoMembershipRecalculator -await recalculator.RecomputeAllMembersAsync(group, ct); -``` - -→ a single SQL query against all person documents, with the script as -a WHERE clause. Result → set `MemberIds` + fire event. - -At a million persons this could be slow — but modgud is -currently sized for an order of magnitude well below that -(mid-sized SaaS org charts, a few thousand users per realm). +When an admin creates a new auto-group (or changes the script), an initial full pass runs — `IAutoMembershipRecalculator.RecalculateForGroupAsync` issues a single SQL query against all person documents with the script as the `WHERE` clause, sets `MemberIds`, and fires the recompute event. Modgud is sized for mid-sized org charts (a few thousand users per realm), where this is sub-second; it is not built for million-row tenants. ## Example setup ``` -Group "OU Sales" (Auto) - Script: (p) => p.OrganizationalUnit === "sales" && p.IsActive - Roles: ["Sales Read", "Customer Manager"] - Group "Active Engineers" (Auto) - Script: (p) => p.Department === "engineering" + Script: (p) => Type.Is(p, 'person') && p.IsActive - && !p.AccountName.startsWith("svc-") + && p.AccountName != null + && p.AccountName.startsWith('eng-') Roles: ["Code Repo Reader", "CI Trigger"] + +Group "Entra Admins" (Auto, ExternallyDrivable) + Script: (p) => Type.Is(p, 'person') && p.ExternalGroups.includes('entra-admins') + Roles: ["Tenant Operator"] ``` -When a new sales user is provisioned via OIDC login: - -1. `UserCreated` with `OrganizationalUnit=sales` fires -2. `AutoMembershipSyncHandlers` evaluates both auto-scripts: - - "OU Sales" matches → user is added to membership - - "Active Engineers" doesn't match → no effect -3. `GroupMembershipRecomputedEvent` fires for "OU Sales" -4. SignalR notification to all admin browsers → the group list in the - frontend updates automatically (via `useEntityService` - subscriptions) -5. The user automatically inherits all permissions of "OU Sales" → - can see customer data immediately, without anyone clicking - anything +When a new engineer is provisioned (a person with `AccountName` `eng-…` is created): + +1. The create event fires. +2. The durable engine evaluates the non-drivable auto-scripts: "Active Engineers" matches → the user is added to `MemberIds`; a recompute event fires. +3. SignalR pushes the change to admin browsers → the group list updates live (via `useEntityService` subscriptions). +4. The user inherits "Active Engineers"' permissions immediately. + +When that same user later signs in through the trusted EntraID provider and the assertion carries `groups: ["entra-admins"]`: + +1. The federation deriver evaluates the `ExternallyDrivable` "Entra Admins" script in memory → match. +2. "Tenant Operator" is unioned into **this session's** access — no `MemberIds` write. +3. The grant carries it for the session's lifetime; the next password login (or a login through an untrusted provider) carries it no more. diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase0Tests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase0Tests.cs new file mode 100644 index 00000000..3f0e0ee3 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase0Tests.cs @@ -0,0 +1,181 @@ +using BuildingBlocks.Helper; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authentication.Domain.LoginProviders; +using Modgud.Authentication.Domain.LoginProviders.Events; +using Modgud.Authorization.Commands; +using Modgud.Authorization.Membership; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Services; +using ErrorOr; +using Marten; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Api.Tests.Authorization; + +/// +/// Federation v1 — Phase 0 (flags, store, config guard). Pins the three new +/// flags persist + project + round-trip, the realm:admin-local-only config guard +/// (decision G), and the fail-closed default. No login-path behavior is wired yet +/// (Phases 1–4); these tests cover only the additive config/store surface. +/// +[Collection(IntegrationTestCollection.Name)] +public class FederationV1Phase0Tests : IntegrationTestBase +{ + public FederationV1Phase0Tests(SharedPostgresFixture fixture) : base(fixture) { } + + [Fact] + public async Task CreateGroup_ExternallyDrivable_With_RealmAdminRole_Is_Rejected() + { + var ct = TestContext.Current.CancellationToken; + var adminRole = await Factory.CreateTestRoleAsync( + $"RealmAdmin_{Guid.NewGuid():N}", isRealmAdmin: true); + + using var scope = Factory.Services.CreateScope(); + var handler = NewCreateGroupHandler(scope.ServiceProvider); + + var result = await handler.Handle(new CreateGroupCommand( + Name: $"Drivable_{Guid.NewGuid():N}", Description: null, + MemberIds: [], RoleIds: [adminRole.Id], + ExternallyDrivable: true), ct); + + Assert.True(result.IsError); + Assert.Contains(result.Errors, e => e.Code == "Group.ExternallyDrivableRealmAdmin"); + } + + [Fact] + public async Task CreateGroup_ExternallyDrivable_With_NormalRole_Succeeds_And_Projects() + { + var ct = TestContext.Current.CancellationToken; + var role = await Factory.CreateTestRoleAsync($"Normal_{Guid.NewGuid():N}"); + + using var scope = Factory.Services.CreateScope(); + var handler = NewCreateGroupHandler(scope.ServiceProvider); + + var result = await handler.Handle(new CreateGroupCommand( + Name: $"Drivable_{Guid.NewGuid():N}", Description: null, + MemberIds: [], RoleIds: [role.Id], + ExternallyDrivable: true), ct); + + Assert.False(result.IsError); + // The handler returns the projected document — proves PrincipalProjectionBase + // materializes the flag (the seam the original integration map omitted). + Assert.True(result.Value.ExternallyDrivable); + } + + [Fact] + public async Task CreateGroup_Default_ExternallyDrivable_Is_False() + { + var ct = TestContext.Current.CancellationToken; + + using var scope = Factory.Services.CreateScope(); + var handler = NewCreateGroupHandler(scope.ServiceProvider); + + var result = await handler.Handle(new CreateGroupCommand( + Name: $"Plain_{Guid.NewGuid():N}", Description: null, + MemberIds: [], RoleIds: []), ct); + + Assert.False(result.IsError); + Assert.False(result.Value.ExternallyDrivable); + } + + [Fact] + public async Task UpdateGroup_ExternallyDrivable_With_RealmAdminRole_Is_Rejected() + { + var ct = TestContext.Current.CancellationToken; + var adminRole = await Factory.CreateTestRoleAsync( + $"RealmAdmin_{Guid.NewGuid():N}", isRealmAdmin: true); + var group = await Factory.CreateTestGroupAsync( + name: $"Plain_{Guid.NewGuid():N}", memberIds: [], roleIds: []); + + using var scope = Factory.Services.CreateScope(); + var handler = NewUpdateGroupHandler(scope.ServiceProvider); + + var result = await handler.Handle(new UpdateGroupCommand( + Id: group.Id, Name: group.Name, Description: null, + MemberIds: [], RoleIds: [adminRole.Id], + ExternallyDrivable: true), ct); + + Assert.True(result.IsError); + Assert.Contains(result.Errors, e => e.Code == "Group.ExternallyDrivableRealmAdmin"); + } + + [Fact] + public async Task LoginProvider_Federation_Flags_RoundTrip_Through_Events() + { + var ct = TestContext.Current.CancellationToken; + var id = Guid.CreateVersion7(); + + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + session.Events.StartStream(id, new LoginProviderAddedEvent( + Id: id, + Type: LoginProviderType.Oidc, + Flavor: LoginProviderFlavor.GenericOidc, + Slug: $"fed-{Guid.NewGuid():N}"[..20], + DisplayName: $"Fed_{Guid.NewGuid():N}", + Description: null, + IsBuiltIn: false, + Enabled: false, + ClientId: "client", + ClientSecretEncrypted: null, + Scopes: ["openid"], + UserUpdateScript: string.Empty, + StoreRawClaims: false, + RawClaimsRetentionDays: null, + AutoCreateUsers: false, + AllowLinking: true, + TrustForEmailLink: false, + AllowedEmailDomains: null, + IconName: null, + ButtonColorHex: null, + FlavorData: null, + CreatedAt: DateTimeOffset.UtcNow, + TrustForAuthorization: true, + AuthoritativeForProfile: true)); + await session.SaveChangesAsync(ct); + + var created = await session.LoadAsync(id, ct); + Assert.NotNull(created); + Assert.True(created!.TrustForAuthorization); + Assert.True(created.AuthoritativeForProfile); + + // Full-replace update flips one flag — the projection must apply it. + session.Events.Append(id, new LoginProviderUpdatedEvent( + Id: id, + DisplayName: created.DisplayName, + Description: null, + ClientId: created.ClientId, + Scopes: created.Scopes, + UserUpdateScript: created.UserUpdateScript, + StoreRawClaims: false, + RawClaimsRetentionDays: null, + AutoCreateUsers: false, + AllowLinking: true, + TrustForEmailLink: false, + AllowedEmailDomains: null, + IconName: null, + ButtonColorHex: null, + FlavorData: null, + UpdatedAt: DateTimeOffset.UtcNow, + TrustForAuthorization: false, + AuthoritativeForProfile: true)); + await session.SaveChangesAsync(ct); + + var updated = await session.LoadAsync(id, ct); + Assert.NotNull(updated); + Assert.False(updated!.TrustForAuthorization); + Assert.True(updated.AuthoritativeForProfile); + } + + private static CreateGroupHandler NewCreateGroupHandler(IServiceProvider sp) => new( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()); + + private static UpdateGroupHandler NewUpdateGroupHandler(IServiceProvider sp) => new( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()); +} diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase2Tests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase2Tests.cs new file mode 100644 index 00000000..d7ef787a --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase2Tests.cs @@ -0,0 +1,171 @@ +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authorization.Apps; +using Modgud.Authorization.Events; +using Modgud.Authorization.Membership; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Services; +using Marten; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Api.Tests.Authorization; + +/// +/// Federation v1 — Phase 2 (login-time membership deriver + two-engine parity). +/// Pins: the deriver evaluates ONLY Auto+ExternallyDrivable groups in-memory over +/// an EvalPrincipal (local Person ∪ provider groups), never writes MemberIds, +/// defensively drops realm:admin-conferring groups; and the in-memory engine +/// agrees with the persisted JSONB-batch engine on the shared local fields +/// (the reconciliation guardrail). +/// +[Collection(IntegrationTestCollection.Name)] +public class FederationV1Phase2Tests : IntegrationTestBase +{ + public FederationV1Phase2Tests(SharedPostgresFixture fixture) : base(fixture) { } + + private const string GroupScript = + "(p) => Type.Is(p, 'person') && p.IsActive && p.ExternalGroups.includes('entra-admins')"; + + [Fact] + public async Task Deriver_Matches_ExternallyDrivable_Group_Via_ExternalGroups() + { + var ct = TestContext.Current.CancellationToken; + var user = await Factory.CreateTestUserWithIdentityAsync("Fed", "Driver", "FD", "fed-driver@acme.com"); + + var drivable = await CreateAutoGroupAsync(GroupScript, externallyDrivable: true); + var notDrivable = await CreateAutoGroupAsync(GroupScript, externallyDrivable: false); + + using var scope = Factory.Services.CreateScope(); + var deriver = scope.ServiceProvider.GetRequiredService(); + + var matched = await deriver.DeriveAsync(user.Id, ["entra-admins", "all-staff"], "provider:test", ct); + + Assert.Contains(drivable, matched.MatchedGroupIds); + Assert.DoesNotContain(notDrivable, matched.MatchedGroupIds); // batch-only group never derived + + // No upstream group → no match. + var none = await deriver.DeriveAsync(user.Id, ["all-staff"], "provider:test", ct); + Assert.DoesNotContain(drivable, none.MatchedGroupIds); + } + + [Fact] + public async Task Deriver_Drops_RealmAdmin_Conferring_Group() + { + var ct = TestContext.Current.CancellationToken; + var user = await Factory.CreateTestUserWithIdentityAsync("Fed", "Admin", "FA", "fed-admin@acme.com"); + var adminRole = await Factory.CreateTestRoleAsync($"RealmAdmin_{Guid.NewGuid():N}", isRealmAdmin: true); + + // Bypass the write-time config guard by emitting the event directly — this + // is exactly the slipped-through case the deriver's defensive strip covers. + var alwaysMatch = "(p) => Type.Is(p, 'person')"; + var rogue = await CreateAutoGroupAsync(alwaysMatch, externallyDrivable: true, roleIds: [adminRole.Id]); + + using var scope = Factory.Services.CreateScope(); + var deriver = scope.ServiceProvider.GetRequiredService(); + + var matched = await deriver.DeriveAsync(user.Id, [], "provider:test", ct); + Assert.DoesNotContain(rogue, matched.MatchedGroupIds); + } + + [Fact] + public async Task Deriver_Never_Writes_MemberIds() + { + var ct = TestContext.Current.CancellationToken; + var user = await Factory.CreateTestUserWithIdentityAsync("Fed", "Ephemeral", "FE", "fed-eph@acme.com"); + var drivable = await CreateAutoGroupAsync(GroupScript, externallyDrivable: true); + + using var scope = Factory.Services.CreateScope(); + var deriver = scope.ServiceProvider.GetRequiredService(); + var matched = await deriver.DeriveAsync(user.Id, ["entra-admins"], "provider:test", ct); + Assert.Contains(drivable, matched.MatchedGroupIds); + + // The match is session-only — durable MemberIds must stay empty. + await using var read = GetTenantedSession(); + var group = await read.LoadAsync(drivable, ct); + Assert.NotNull(group); + Assert.DoesNotContain(user.Id, group!.MemberIds); + } + + [Fact] + public async Task Reconciliation_InMemory_Agrees_With_JsonbBatch_On_Local_Fields() + { + var ct = TestContext.Current.CancellationToken; + + // Edge cases: domain match, mixed-case, non-match. + var alice = await Factory.CreateTestUserWithIdentityAsync("Alice", "A", "AA", "alice@acme.com"); + var bob = await Factory.CreateTestUserWithIdentityAsync("Bob", "B", "BB", "bob@ACME.com"); + var carol = await Factory.CreateTestUserWithIdentityAsync("Carol", "C", "CC", "carol@contoso.com"); + var users = new[] { alice.Id, bob.Id, carol.Id }; + + const string localScript = + "(p) => Type.Is(p, 'person') && p.Email != null && p.Email.endsWith('@acme.com')"; + + // ── JSONB batch engine: a non-drivable Auto group, materialized via SQL. + var batchGroupId = await CreateAutoGroupAsync(localScript, externallyDrivable: false); + using (var scope = Factory.Services.CreateScope()) + { + var session = scope.ServiceProvider.GetRequiredService(); + var recalc = scope.ServiceProvider.GetRequiredService(); + var group = await session.LoadAsync(batchGroupId, ct); + await recalc.RecalculateForGroupAsync(group!, session, ct); + await session.SaveChangesAsync(ct); + } + + HashSet batchMembers; + await using (var read = GetTenantedSession()) + { + var group = await read.LoadAsync(batchGroupId, ct); + batchMembers = group!.MemberIds.ToHashSet(); + } + + // ── In-memory engine: evaluate the SAME script over an EvalPrincipal + // hydrated from each persisted Person. + using var scope2 = Factory.Services.CreateScope(); + var query = scope2.ServiceProvider.GetRequiredService(); + var evaluator = scope2.ServiceProvider.GetRequiredService(); + var compiled = evaluator.TranspileMembershipScript(localScript); + var predicate = evaluator.BuildPredicate(compiled, ct).Compile(); + + foreach (var id in users) + { + var person = await query.LoadAsync(id, ct); + var eval = new EvalPrincipal + { + Id = person!.Id, + IsActive = person.IsActive, + IsDeleted = person.IsDeleted, + AccountName = person.AccountName, + Firstname = person.Firstname, + Lastname = person.Lastname, + Acronym = person.Acronym, + Email = person.Email, + NormalizedUserName = person.NormalizedUserName, + NormalizedEmail = person.NormalizedEmail, + ExternalIdentities = person.ExternalIdentities, + }; + var inMemory = predicate(eval); + var inBatch = batchMembers.Contains(id); + Assert.True(inMemory == inBatch, + $"Engine divergence for {person.Email}: in-memory={inMemory}, batch={inBatch}"); + } + } + + private async Task CreateAutoGroupAsync( + string script, bool externallyDrivable, List? roleIds = null) + { + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + var evaluator = scope.ServiceProvider.GetRequiredService(); + var compiled = evaluator.TranspileMembershipScript(script); + + var id = Guid.CreateVersion7(); + session.Events.StartStream(id, new GroupCreatedEvent( + id, $"Fed_{Guid.NewGuid():N}", null, + [], roleIds ?? [], + MembershipMode.Auto, script, compiled, null, + null, EmailMode.Shared, + [AppSlugs.Modgud], + externallyDrivable)); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return id; + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase4Tests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase4Tests.cs new file mode 100644 index 00000000..339173db --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/FederationV1Phase4Tests.cs @@ -0,0 +1,215 @@ +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authorization.Apps; +using Modgud.Authorization.Events; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Services; +using Modgud.Permissions; +using Marten; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Api.Tests.Authorization; + +/// +/// Federation v1 — Phase 4 (token/grant union, read side). Pins the +/// union overloads that combine durable +/// membership with the session-derived sessionGroupIds carried on the +/// grant: +/// +/// a session group contributes its roles/permissions for the bound app; +/// a session group's ANCESTORS are inherited (a session child still +/// confers its parents' roles); +/// realm:admin is hard local-only — a session-sourced group never +/// confers it (even via an ancestor role), while a durable realm:admin is +/// kept (provenance-aware, not a blanket strip); +/// BoundTo still gates session groups; +/// an empty session set is byte-for-byte the no-arg behavior. +/// +/// The hub-boundary leak guard (the carrier never reaching a token) is pinned +/// separately as a unit test in AuthorizationEndpointHelpersTests. +/// +[Collection(IntegrationTestCollection.Name)] +public class FederationV1Phase4Tests : IntegrationTestBase +{ + public FederationV1Phase4Tests(SharedPostgresFixture fixture) : base(fixture) { } + + private const string AppSlug = "phase4-app"; + + [Fact] + public async Task SessionGroup_Contributes_Roles_And_Permissions_For_Bound_App() + { + var ct = TestContext.Current.CancellationToken; + await CreateAppAsync(AppSlug, [("policy", "read"), ("policy", "write")]); + + var user = await Factory.CreateTestUserWithIdentityAsync("Sess", "Union", "SU", "sess-union@acme.com"); + var role = await Factory.CreateTestRoleAsync( + $"R_{Guid.NewGuid():N}", [("policy", "write")], appSlug: AppSlug); + // The user is NOT a durable member of this group — it only enters via the + // session carrier. + var sessionGroup = await Factory.CreateTestGroupAsync( + $"SG_{Guid.NewGuid():N}", memberIds: [], roleIds: [role.Id], boundTo: [AppSlug]); + + using var s = Factory.Services.CreateScope(); + var svc = s.ServiceProvider.GetRequiredService(); + + // Without the carrier: durable membership is empty → nothing. + var durableOnly = await svc.GetUserPermissionsAsync(user.Id, AppSlug, [], ct); + Assert.Empty(durableOnly); + + // With the carrier: the session group's permission is unioned in. + var withSession = await svc.GetUserPermissionsAsync(user.Id, AppSlug, [sessionGroup.Id], ct); + Assert.Contains("policy:write", withSession); + Assert.DoesNotContain("policy:read", withSession); + + // Roles overload picks the same group up. + var roles = await svc.GetUserRolesAsync(user.Id, AppSlug, [sessionGroup.Id], ct); + Assert.Contains(role.Id, roles.Select(r => r.Id)); + + // Groups overload includes the session group itself. + var groups = await svc.GetUserGroupsAsync(user.Id, [sessionGroup.Id], ct); + Assert.Contains(sessionGroup.Id, groups.Select(g => g.Id)); + } + + [Fact] + public async Task SessionGroup_Ancestor_Roles_Are_Inherited() + { + var ct = TestContext.Current.CancellationToken; + await CreateAppAsync(AppSlug, [("policy", "read"), ("policy", "write")]); + + var user = await Factory.CreateTestUserWithIdentityAsync("Sess", "Ancestor", "SA", "sess-anc@acme.com"); + + var parentRole = await Factory.CreateTestRoleAsync( + $"RP_{Guid.NewGuid():N}", [("policy", "read")], appSlug: AppSlug); + var childRole = await Factory.CreateTestRoleAsync( + $"RC_{Guid.NewGuid():N}", [("policy", "write")], appSlug: AppSlug); + + // child is a MEMBER of parent → parent is the child's ancestor; roles flow + // up the member-of graph. Create the child first so the parent can list it. + var child = await Factory.CreateTestGroupAsync( + $"Child_{Guid.NewGuid():N}", memberIds: [], roleIds: [childRole.Id], boundTo: [AppSlug]); + var parent = await Factory.CreateTestGroupAsync( + $"Parent_{Guid.NewGuid():N}", memberIds: [child.Id], roleIds: [parentRole.Id], boundTo: [AppSlug]); + + using var s = Factory.Services.CreateScope(); + var svc = s.ServiceProvider.GetRequiredService(); + + // Session-place the user into the CHILD only — they must still inherit the + // PARENT's role through the ancestor walk. + var permissions = await svc.GetUserPermissionsAsync(user.Id, AppSlug, [child.Id], ct); + Assert.Contains("policy:write", permissions); // direct (child role) + Assert.Contains("policy:read", permissions); // inherited (parent role) + + var groups = await svc.GetUserGroupsAsync(user.Id, [child.Id], ct); + Assert.Contains(child.Id, groups.Select(g => g.Id)); + Assert.Contains(parent.Id, groups.Select(g => g.Id)); + } + + [Fact] + public async Task SessionGroup_Cannot_Confer_RealmAdmin_But_Durable_Is_Kept() + { + var ct = TestContext.Current.CancellationToken; + await CreateAppAsync(AppSlug, [("policy", "read")]); + + var sessionUser = await Factory.CreateTestUserWithIdentityAsync("Sess", "Rogue", "SR", "sess-rogue@acme.com"); + var durableUser = await Factory.CreateTestUserWithIdentityAsync("Dur", "Admin", "DA", "dur-admin@acme.com"); + + var realmAdminRole = await Factory.CreateTestRoleAsync( + $"RA_{Guid.NewGuid():N}", isRealmAdmin: true); + var readRole = await Factory.CreateTestRoleAsync( + $"RR_{Guid.NewGuid():N}", [("policy", "read")], appSlug: AppSlug); + + // A non-realm-admin child group; its ancestor confers realm:admin. This is + // exactly the case the write-time config guard can't see (the child itself + // is harmless; the danger is the inherited parent role). + var child = await Factory.CreateTestGroupAsync( + $"RogueChild_{Guid.NewGuid():N}", memberIds: [], roleIds: [readRole.Id], boundTo: [AppSlug]); + // The realm-admin parent: durableUser is a DIRECT member; child is a member + // (so the parent is the child's ancestor). Wildcard-bound like real + // realm-admin groups. + await Factory.CreateTestGroupAsync( + $"RealmAdmins_{Guid.NewGuid():N}", + memberIds: [durableUser.Id, child.Id], roleIds: [realmAdminRole.Id], boundTo: ["*"]); + + using var s = Factory.Services.CreateScope(); + var svc = s.ServiceProvider.GetRequiredService(); + + // Session path: realm:admin reached ONLY via the session carrier → stripped. + // The non-privileged inherited permission still comes through. + var sessionPerms = await svc.GetUserPermissionsAsync(sessionUser.Id, AppSlug, [child.Id], ct); + Assert.DoesNotContain(PermissionEvaluator.RealmAdminPermission, sessionPerms); + Assert.Contains("policy:read", sessionPerms); + + // The realm-admin role itself must not leak into the roles emission either. + var sessionRoles = await svc.GetUserRolesAsync(sessionUser.Id, AppSlug, [child.Id], ct); + Assert.DoesNotContain(realmAdminRole.Id, sessionRoles.Select(r => r.Id)); + + // Durable path: a genuinely-held realm:admin is kept (the strip is + // provenance-aware, not a blanket removal). + var durablePerms = await svc.GetUserPermissionsAsync(durableUser.Id, AppSlug, [], ct); + Assert.Contains(PermissionEvaluator.RealmAdminPermission, durablePerms); + } + + [Fact] + public async Task SessionGroup_Not_Bound_To_App_Contributes_Nothing() + { + var ct = TestContext.Current.CancellationToken; + await CreateAppAsync(AppSlug, [("policy", "write")]); + + var user = await Factory.CreateTestUserWithIdentityAsync("Sess", "Bound", "SB", "sess-bound@acme.com"); + var role = await Factory.CreateTestRoleAsync( + $"RB_{Guid.NewGuid():N}", [("policy", "write")], appSlug: AppSlug); + // Bound to a DIFFERENT app — dormant for phase4-app. + var sessionGroup = await Factory.CreateTestGroupAsync( + $"SGother_{Guid.NewGuid():N}", memberIds: [], roleIds: [role.Id], boundTo: ["some-other-app"]); + + using var s = Factory.Services.CreateScope(); + var svc = s.ServiceProvider.GetRequiredService(); + + var permissions = await svc.GetUserPermissionsAsync(user.Id, AppSlug, [sessionGroup.Id], ct); + Assert.Empty(permissions); + } + + [Fact] + public async Task Empty_SessionGroups_Overload_Equals_NoArg() + { + var ct = TestContext.Current.CancellationToken; + await CreateAppAsync(AppSlug, [("policy", "read"), ("policy", "write")]); + + var user = await Factory.CreateTestUserWithIdentityAsync("Dur", "Parity", "DP", "dur-parity@acme.com"); + var role = await Factory.CreateTestRoleAsync( + $"RD_{Guid.NewGuid():N}", [("policy", "write")], appSlug: AppSlug); + await Factory.CreateTestGroupAsync( + $"GD_{Guid.NewGuid():N}", memberIds: [user.Id], roleIds: [role.Id], boundTo: [AppSlug]); + + using var s = Factory.Services.CreateScope(); + var svc = s.ServiceProvider.GetRequiredService(); + + var permsNoArg = (await svc.GetUserPermissionsAsync(user.Id, AppSlug, ct)).OrderBy(x => x).ToList(); + var permsEmpty = (await svc.GetUserPermissionsAsync(user.Id, AppSlug, [], ct)).OrderBy(x => x).ToList(); + Assert.Equal(permsNoArg, permsEmpty); + + var rolesNoArg = (await svc.GetUserRolesAsync(user.Id, AppSlug, ct)).Select(r => r.Id).OrderBy(x => x).ToList(); + var rolesEmpty = (await svc.GetUserRolesAsync(user.Id, AppSlug, [], ct)).Select(r => r.Id).OrderBy(x => x).ToList(); + Assert.Equal(rolesNoArg, rolesEmpty); + + var groupsNoArg = (await svc.GetUserGroupsAsync(user.Id, ct)).Select(g => g.Id).OrderBy(x => x).ToList(); + var groupsEmpty = (await svc.GetUserGroupsAsync(user.Id, [], ct)).Select(g => g.Id).OrderBy(x => x).ToList(); + Assert.Equal(groupsNoArg, groupsEmpty); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private async Task CreateAppAsync(string slug, IReadOnlyList<(string Resource, string Action)> permissions) + { + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + var perms = permissions + .Select(p => new AppPermission(Guid.NewGuid(), p.Resource, p.Action, Description: null)) + .ToList(); + var appId = Guid.NewGuid(); + session.Events.StartStream(appId, new AppCreatedEvent( + Id: appId, Slug: slug, DisplayName: slug, Description: null, + Permissions: perms, IsSystem: false)); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs index cfc2bb85..f2339a06 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs @@ -1,17 +1,25 @@ using System.Net; using System.Net.Http.Json; +using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Modgud.Api.Tests.Infrastructure; using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; +using Modgud.Authentication.Domain; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; using Modgud.Domain.OAuth.Apis; using Modgud.Domain.OAuth.Common; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Permissions.Abstractions; using Marten; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenIddict.Abstractions; namespace Modgud.Api.Tests.Authorization; @@ -87,6 +95,375 @@ await GrantAsync(testUser.Id, roleAppSlug: "app-alpha", resourceType: "policy", "app must not appear in the public RS-block."); } + [Fact] + public async Task UserInfo_ReferenceToken_Client_Emits_Concrete_Permission() + { + // Same assertion as the JWT-client case above, but the client is + // configured for OPAQUE REFERENCE access tokens (the federation-v1 + // default — decision I14). The reference token's stored payload is a + // realm-signed JWT; /connect/userinfo must resolve the reference, + // load the payload, and validate its signature against the REALM key. + // Regression guard for the RealmTokenValidationHandler bug where the + // IsReferenceToken early-return left the global key pool in place → + // 401 invalid_token (ID2090, "signing key not found"). + var appAlpha = await CreateAppAsync("app-alpha", "App Alpha", + permissions: [("policy", "read"), ("policy", "write"), ("policy", "admin")]); + const string alphaAudience = "https://alpha-api.example.com"; + await CreateOAuthApiAsync(alphaAudience, appAlpha.Id); + + const string alphaScopeName = "alpha-api"; + await CreateScopeAsync(name: alphaScopeName, resources: [alphaAudience], appId: appAlpha.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-ref-spa-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, + appIds: [appAlpha.Id], scopes: ["openid", "roles", "permissions", alphaScopeName], + accessTokenType: AccessTokenType.Reference); + + var testUser = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Reference", lastname: "Token", acronym: "rt", + email: "rt@test.com", password: "TestPass1234"); + + await GrantAsync(testUser.Id, roleAppSlug: "app-alpha", resourceType: "policy", + actions: ["write"], groupBoundTo: ["app-alpha"]); + + var alphaBlock = await DriveFlowAndReadAlphaBlockAsync( + "rt", clientId, clientSecret, redirectUri, alphaScopeName, alphaAudience); + + var permissions = ReadStringArray(alphaBlock, "permissions"); + Assert.Contains("policy:write", permissions); + Assert.DoesNotContain("policy:read", permissions); + Assert.DoesNotContain("policy:admin", permissions); + } + + [Fact] + public async Task ReferenceToken_RefreshRedemption_Then_UserInfo_Succeeds() + { + // Reference REFRESH tokens are signed with the GLOBAL pool (not the + // realm key — see RealmSigningKeyHandler), so the realm-key install in + // RealmTokenValidationHandler must NOT break their redemption at + // /connect/token. This drives a reference client through + // authorize → token (with offline_access) → refresh-redeem → userinfo, + // proving both the refresh path and the (newly fixed) reference-access + // userinfo path work together. + var appAlpha = await CreateAppAsync("app-alpha", "App Alpha", + permissions: [("policy", "read"), ("policy", "write")]); + const string alphaAudience = "https://alpha-api.example.com"; + await CreateOAuthApiAsync(alphaAudience, appAlpha.Id); + + const string alphaScopeName = "alpha-api"; + await CreateScopeAsync(name: alphaScopeName, resources: [alphaAudience], appId: appAlpha.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-ref-refresh-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, + appIds: [appAlpha.Id], + scopes: ["openid", "offline_access", "roles", "permissions", alphaScopeName], + accessTokenType: AccessTokenType.Reference); + + var testUser = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Refresh", lastname: "Reference", acronym: "rr", + email: "rr@test.com", password: "TestPass1234"); + await GrantAsync(testUser.Id, roleAppSlug: "app-alpha", resourceType: "policy", + actions: ["write"], groupBoundTo: ["app-alpha"]); + + // authorize → token (with offline_access so a refresh token is issued) + using var tokens = await DriveAuthCodeFlowForTokensAsync( + username: "rr", password: "TestPass1234", + clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, + scope: $"openid offline_access roles permissions {alphaScopeName}", + resources: [alphaAudience]); + var refreshToken = tokens.RootElement.GetProperty("refresh_token").GetString()!; + + // redeem the refresh token → fresh reference access token + var newAccessToken = await RedeemRefreshTokenAsync( + refreshToken, clientId, clientSecret, [alphaAudience]); + + // the fresh access token must still resolve at /connect/userinfo + var userinfoClient = Factory.CreateClient(); + userinfoClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", newAccessToken); + var response = await userinfoClient.GetAsync("/connect/userinfo", + TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(response.IsSuccessStatusCode, + $"/connect/userinfo after refresh failed ({(int)response.StatusCode}): {body}"); + + using var doc = JsonDocument.Parse(body); + Assert.True(doc.RootElement.TryGetProperty("resource_access", out var ra) + && ra.TryGetProperty(alphaAudience, out _), + $"resource_access['{alphaAudience}'] missing after refresh.\nBody:\n{body}"); + } + + [Fact] + public async Task JwtClient_Bakes_ResourceAccess_Into_AccessToken_And_UserInfo_Echoes() + { + // Federation v1.1: a JWT-access client has no server-side token payload for + // UserInfo to read the session-group carrier back from, so the per-audience + // resource_access (durable ∪ session-derived, computed via the same + // BuildResourceAccessAsync as the reference path) is baked into the + // self-contained access token at issuance. This pins the wiring end-to-end: + // (1) the block is present IN the JWT payload (no UserInfo round-trip + // needed), and (2) UserInfo echoes that block verbatim rather than silently + // recomputing a narrower set. (The session-derived union itself is pinned at + // the service level by FederationV1Phase4Tests; the bake path is identical + // for durable vs session membership.) + var slug = "jwtfed-" + Guid.NewGuid().ToString("N")[..8]; + var audience = $"https://{slug}-api.example.com"; + var scopeName = slug + "-api"; + + var app = await CreateAppAsync(slug, "Jwt Fed App", + permissions: [("policy", "read"), ("policy", "write"), ("policy", "admin")]); + await CreateOAuthApiAsync(audience, app.Id); + await CreateScopeAsync(name: scopeName, resources: [audience], appId: app.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-jwt-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, + appIds: [app.Id], scopes: ["openid", "roles", "permissions", scopeName], + accessTokenType: AccessTokenType.Jwt); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Jwt", lastname: "Fed", acronym: "jf", email: "jf@test.com", password: "TestPass1234"); + await GrantAsync(user.Id, roleAppSlug: slug, resourceType: "policy", + actions: ["write"], groupBoundTo: [slug]); + + var accessToken = await DriveAuthCodeFlowAsync( + username: "jf", password: "TestPass1234", clientId: clientId, clientSecret: clientSecret, + redirectUri: redirectUri, scope: $"openid roles permissions {scopeName}", resources: [audience]); + + // (1) resource_access is baked into the self-contained JWT. + var payload = DecodeJwtPayload(accessToken); + Assert.True(payload.TryGetProperty("resource_access", out var ra), + $"resource_access missing from JWT payload:\n{payload}"); + Assert.True(ra.TryGetProperty(audience, out var block), + $"resource_access['{audience}'] missing from JWT. keys: {string.Join(",", ra.EnumerateObject().Select(p => p.Name))}"); + var permsInToken = block.GetProperty("permissions").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("policy:write", permsInToken); + Assert.DoesNotContain("policy:read", permsInToken); + + // (2) UserInfo echoes the same block. + var userinfoClient = Factory.CreateClient(); + userinfoClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + var resp = await userinfoClient.GetAsync("/connect/userinfo", TestContext.Current.CancellationToken); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(resp.IsSuccessStatusCode, $"/connect/userinfo failed ({(int)resp.StatusCode}): {body}"); + using var doc = JsonDocument.Parse(body); + var uiPerms = doc.RootElement.GetProperty("resource_access").GetProperty(audience) + .GetProperty("permissions").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("policy:write", uiPerms); + Assert.DoesNotContain("policy:read", uiPerms); + } + + [Fact] + public async Task JwtClient_BakedResourceAccess_Honours_Requested_Audience_Only() + { + // The baked block must match the token's narrowed aud (RFC 8707 resource + // indicators), never the broader scope-derived set — otherwise a block for + // an audience the client didn't request would ride along in the token. + var aSlug = "jwtaud-a-" + Guid.NewGuid().ToString("N")[..8]; + var bSlug = "jwtaud-b-" + Guid.NewGuid().ToString("N")[..8]; + var aAud = $"https://{aSlug}.example.com"; + var bAud = $"https://{bSlug}.example.com"; + + var appA = await CreateAppAsync(aSlug, "App A", permissions: [("policy", "write")]); + var appB = await CreateAppAsync(bSlug, "App B", permissions: [("widget", "write")]); + await CreateOAuthApiAsync(aAud, appA.Id); + await CreateOAuthApiAsync(bAud, appB.Id); + await CreateScopeAsync(name: aSlug + "-api", resources: [aAud], appId: appA.Id); + await CreateScopeAsync(name: bSlug + "-api", resources: [bAud], appId: appB.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-jwt-2aud-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, + appIds: [appA.Id, appB.Id], + scopes: ["openid", "roles", "permissions", aSlug + "-api", bSlug + "-api"], + accessTokenType: AccessTokenType.Jwt); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Two", lastname: "Aud", acronym: "ta", email: "ta@test.com", password: "TestPass1234"); + await GrantAsync(user.Id, roleAppSlug: aSlug, resourceType: "policy", actions: ["write"], groupBoundTo: [aSlug]); + await GrantAsync(user.Id, roleAppSlug: bSlug, resourceType: "widget", actions: ["write"], groupBoundTo: [bSlug]); + + // Request ONLY audience A as the resource indicator. + var accessToken = await DriveAuthCodeFlowAsync( + username: "ta", password: "TestPass1234", clientId: clientId, clientSecret: clientSecret, + redirectUri: redirectUri, + scope: $"openid roles permissions {aSlug}-api {bSlug}-api", resources: [aAud]); + + var payload = DecodeJwtPayload(accessToken); + Assert.True(payload.TryGetProperty("resource_access", out var ra), + $"resource_access missing from JWT payload:\n{payload}"); + Assert.True(ra.TryGetProperty(aAud, out _), "audience A block expected (it was requested)."); + Assert.False(ra.TryGetProperty(bAud, out _), + "audience B block must NOT appear — it was not in the requested resource set."); + } + + private static JsonElement DecodeJwtPayload(string jwt) + { + var parts = jwt.Split('.'); + Assert.True(parts.Length >= 2, $"not a JWT (reference token?): {jwt[..Math.Min(16, jwt.Length)]}…"); + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); + return JsonDocument.Parse(Convert.FromBase64String(payload)).RootElement.Clone(); + } + + [Fact] + public async Task JwtClient_Federated_SessionGroup_Lands_In_AccessToken() + { + // The user is NOT a durable member of the group — it enters authz ONLY via + // the session-group carrier on the (federated) cookie. Proves the full + // carrier path cookie → /connect/authorize grant → JWT bake, end-to-end + // through the real HTTP pipeline (not just durable authz, and not just the + // service-level union pinned by FederationV1Phase4Tests). + var slug = "fedjwt-" + Guid.NewGuid().ToString("N")[..8]; + var audience = $"https://{slug}-api.example.com"; + var scopeName = slug + "-api"; + + var app = await CreateAppAsync(slug, "Fed JWT App", permissions: [("policy", "read"), ("policy", "write")]); + await CreateOAuthApiAsync(audience, app.Id); + await CreateScopeAsync(name: scopeName, resources: [audience], appId: app.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-fedjwt-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, + appIds: [app.Id], scopes: ["openid", "roles", "permissions", scopeName], + accessTokenType: AccessTokenType.Jwt); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Fed", lastname: "Jwt", acronym: "fj", email: "fj@test.com", password: "TestPass1234"); + var role = await Factory.CreateTestRoleAsync($"R_{Guid.NewGuid():N}", [("policy", "write")], appSlug: slug); + var sessionGroup = await Factory.CreateTestGroupAsync( + $"SG_{Guid.NewGuid():N}", memberIds: [], roleIds: [role.Id], boundTo: [slug]); + + // Control: a plain (password) login carries no carrier → durable-only → + // the session group's permission must be absent. + var plainToken = await DriveAuthCodeFlowAsync( + username: "fj", password: "TestPass1234", clientId: clientId, clientSecret: clientSecret, + redirectUri: redirectUri, scope: $"openid roles permissions {scopeName}", resources: [audience]); + var plain = DecodeJwtPayload(plainToken); + var plainHasWrite = + plain.TryGetProperty("resource_access", out var pra) + && pra.TryGetProperty(audience, out var pb) + && pb.TryGetProperty("permissions", out var pp) + && pp.EnumerateArray().Any(e => e.GetString() == "policy:write"); + Assert.False(plainHasWrite, "without the carrier, policy:write must NOT appear (durable membership is empty)."); + + // Federated: the forged cookie carries the session group → policy:write appears. + var fedClient = await CreateFederatedCookieClientAsync("fj", sessionGroup.Id); + var fedToken = await DriveAuthCodeFlowAsync( + username: "fj", password: "TestPass1234", clientId: clientId, clientSecret: clientSecret, + redirectUri: redirectUri, scope: $"openid roles permissions {scopeName}", resources: [audience], + cookieClient: fedClient); + + var perms = DecodeJwtPayload(fedToken).GetProperty("resource_access").GetProperty(audience) + .GetProperty("permissions").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("policy:write", perms); // came ONLY from the session-group carrier + } + + [Fact] + public async Task ReferenceClient_Federated_SessionGroup_Surfaces_At_UserInfo() + { + // Reference client: the carrier rides the server-side reference token and + // the session-derived permission surfaces at /connect/userinfo (the path + // the ID2090 hotfix repaired). User is NOT a durable member. + var slug = "fedref-" + Guid.NewGuid().ToString("N")[..8]; + var audience = $"https://{slug}-api.example.com"; + var scopeName = slug + "-api"; + + var app = await CreateAppAsync(slug, "Fed Ref App", permissions: [("policy", "read"), ("policy", "write")]); + await CreateOAuthApiAsync(audience, app.Id); + await CreateScopeAsync(name: scopeName, resources: [audience], appId: app.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-fedref-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, + appIds: [app.Id], scopes: ["openid", "roles", "permissions", scopeName], + accessTokenType: AccessTokenType.Reference); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Fed", lastname: "Ref", acronym: "fr", email: "fr@test.com", password: "TestPass1234"); + var role = await Factory.CreateTestRoleAsync($"R_{Guid.NewGuid():N}", [("policy", "write")], appSlug: slug); + var sessionGroup = await Factory.CreateTestGroupAsync( + $"SG_{Guid.NewGuid():N}", memberIds: [], roleIds: [role.Id], boundTo: [slug]); + + var fedClient = await CreateFederatedCookieClientAsync("fr", sessionGroup.Id); + var accessToken = await DriveAuthCodeFlowAsync( + username: "fr", password: "TestPass1234", clientId: clientId, clientSecret: clientSecret, + redirectUri: redirectUri, scope: $"openid roles permissions {scopeName}", resources: [audience], + cookieClient: fedClient); + + var userinfoClient = Factory.CreateClient(); + userinfoClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + var resp = await userinfoClient.GetAsync("/connect/userinfo", TestContext.Current.CancellationToken); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(resp.IsSuccessStatusCode, $"/connect/userinfo failed ({(int)resp.StatusCode}): {body}"); + using var doc = JsonDocument.Parse(body); + var perms = doc.RootElement.GetProperty("resource_access").GetProperty(audience) + .GetProperty("permissions").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("policy:write", perms); // session-group permission via the server-side carrier + } + + /// + /// Forges a valid ApplicationScheme auth cookie carrying the federation + /// session-group carrier claim(s), without a real upstream-IdP round-trip. + /// Uses the app's own principal (valid security + /// stamp) + the real TicketDataFormat, protected under the system tenant + /// so the request pipeline (also system tenant) accepts it. This stubs only the + /// deriver→cookie link (covered by FederationV1Phase3Tests); everything + /// downstream — cookie→grant→token/UserInfo — runs for real. + /// + private async Task CreateFederatedCookieClientAsync(string userName, params Guid[] sessionGroupIds) + { + using var scope = Factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var signInManager = scope.ServiceProvider.GetRequiredService>(); + + var user = await userManager.FindByNameAsync(userName) + ?? throw new InvalidOperationException($"user '{userName}' not found"); + var principal = await signInManager.CreateUserPrincipalAsync(user); + var identity = (ClaimsIdentity)principal.Identity!; + foreach (var gid in sessionGroupIds) + identity.AddClaim(new Claim(FederationClaimTypes.SessionGroup, gid.ToString())); + + var cookieOptions = scope.ServiceProvider + .GetRequiredService>() + .Get(IdentityConstants.ApplicationScheme); + var ticket = new AuthenticationTicket( + principal, + new AuthenticationProperties + { + IsPersistent = true, + IssuedUtc = DateTimeOffset.UtcNow, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1), + }, + IdentityConstants.ApplicationScheme); + + // Protect under the system tenant so the request pipeline (system tenant + // by default in tests) resolves the same per-tenant DataProtection keys. + string cookieValue; + using (TenantContext.Enter(TenantConstants.SystemTenantId)) + cookieValue = cookieOptions.TicketDataFormat.Protect(ticket); + + var handler = new CookieContainerHandler(); + handler.Seed(new Uri("http://localhost"), cookieOptions.Cookie.Name!, cookieValue); + return Factory.CreateDefaultClient(handler); + } + [Fact] public async Task UserInfo_ResourceAdmin_Expands_To_All_Resource_Actions() { @@ -215,10 +592,30 @@ private async Task DriveAuthCodeFlowAsync( string clientId, string clientSecret, string redirectUri, string scope, - IReadOnlyList resources) + IReadOnlyList resources, + HttpClient? cookieClient = null) + { + using var json = await DriveAuthCodeFlowForTokensAsync( + username, password, clientId, clientSecret, redirectUri, scope, resources, cookieClient); + return json.RootElement.GetProperty("access_token").GetString()!; + } + + /// Same as but returns the full + /// token-endpoint JSON response (so callers can read the refresh_token too). + /// When is supplied it is used for the + /// /connect/authorize step instead of a fresh password login — the federated + /// tests pass a client carrying a hand-forged cookie with the session-group + /// carrier. + private async Task DriveAuthCodeFlowForTokensAsync( + string username, string password, + string clientId, string clientSecret, + string redirectUri, + string scope, + IReadOnlyList resources, + HttpClient? cookieClient = null) { // 1. Cookie-login first so /connect/authorize sees an authenticated principal. - var cookieClient = await CreateAuthenticatedClientAsync(username, password); + cookieClient ??= await CreateAuthenticatedClientAsync(username, password); // 2. PKCE pair. var verifier = GeneratePkceVerifier(); @@ -278,8 +675,32 @@ private async Task DriveAuthCodeFlowAsync( Assert.True(tokenResponse.IsSuccessStatusCode, $"/connect/token failed ({(int)tokenResponse.StatusCode}): {tokenBody}"); - using var tokenJson = JsonDocument.Parse(tokenBody); - return tokenJson.RootElement.GetProperty("access_token").GetString()!; + return JsonDocument.Parse(tokenBody); + } + + /// Redeems a refresh token at /connect/token and returns the new access token. + private async Task RedeemRefreshTokenAsync( + string refreshToken, string clientId, string clientSecret, IReadOnlyList resources) + { + var tokenClient = Factory.CreateClient(); + var form = new List> + { + new("grant_type", "refresh_token"), + new("refresh_token", refreshToken), + new("client_id", clientId), + new("client_secret", clientSecret), + }; + foreach (var r in resources) + form.Add(new KeyValuePair("resource", r)); + + var resp = await tokenClient.PostAsync( + "/connect/token", new FormUrlEncodedContent(form), TestContext.Current.CancellationToken); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(resp.IsSuccessStatusCode, + $"refresh_token redemption failed ({(int)resp.StatusCode}): {body}"); + + using var json = JsonDocument.Parse(body); + return json.RootElement.GetProperty("access_token").GetString()!; } private static string GeneratePkceVerifier() @@ -351,7 +772,7 @@ private async Task CreateOAuthApiAsync(string name, Guid appId) private async Task CreateOAuthClientAsync( string clientId, string clientSecret, string redirectUri, List appIds, - List scopes) + List scopes, AccessTokenType accessTokenType = AccessTokenType.Jwt) { using var scope = Factory.Services.CreateScope(); var oauthAdmin = scope.ServiceProvider.GetRequiredService(); @@ -368,7 +789,7 @@ private async Task CreateOAuthClientAsync( Scopes = scopes, AllowedGrantTypes = ["authorization_code", "refresh_token"], RequireConsent = false, - AccessTokenType = AccessTokenType.Jwt, + AccessTokenType = accessTokenType, AppIds = [.. appIds.Select(g => new BuildingBlocks.Helper.ShortGuid(g).ToString())], }; diff --git a/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederationV1Phase1Tests.cs b/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederationV1Phase1Tests.cs new file mode 100644 index 00000000..e5fb2d24 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederationV1Phase1Tests.cs @@ -0,0 +1,185 @@ +using System.Security.Claims; +using BuildingBlocks.Helper; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authentication.Api.ExternalAuth; +using Modgud.Authentication.Domain.ExternalAuth; +using Modgud.Authentication.Domain.LoginProviders; +using Modgud.Authentication.Domain.LoginProviders.Events; +using Modgud.Authentication.Gdpr; + +namespace Modgud.Api.Tests.ExternalAuth; + +/// +/// Federation v1 — Phase 1 (claims capture, source-tagged persistence, scrub). +/// Pins: every successful external login refreshes the per-user +/// for the current provider only (delete+rewrite), +/// tagged provider:<slug>; and both deletion paths (GDPR erase + admin +/// delete) scrub the store. No authorization behavior is wired yet (phases 2-4). +/// +[Collection(IntegrationTestCollection.Name)] +public class FederationV1Phase1Tests : IntegrationTestBase +{ + public FederationV1Phase1Tests(SharedPostgresFixture fixture) : base(fixture) { } + + private const string Issuer = "https://idp.federation.test/v2.0"; + + [Fact] + public async Task Login_Persists_Provider_Tagged_Claims_Including_Groups() + { + var ct = TestContext.Current.CancellationToken; + var config = await CreateEnabledOidcProviderAsync(autoCreate: true); + + using var scope = Factory.Services.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + var external = BuildPrincipal("sub-cap-1", "cap1@acme.com", ["IT", "Admins"]); + + var result = await processor.ProcessAsync(external, config.Id, ct); + Assert.True(result.Succeeded); + + await using var read = GetTenantedSession(); + var store = await read.LoadAsync(result.UserId!.Value, ct); + Assert.NotNull(store); + + var source = $"provider:{config.Slug}"; + Assert.All(store!.Claims, e => Assert.Equal(source, e.Source)); + + var groups = store.Claims.Where(e => e.Type == "groups").Select(e => e.Value).ToList(); + Assert.Contains("IT", groups); + Assert.Contains("Admins", groups); + Assert.Contains(store.Claims, e => e.Type == "email" && e.Value == "cap1@acme.com"); + } + + [Fact] + public async Task ReLogin_Replaces_The_Providers_Entries_Not_Appends() + { + var ct = TestContext.Current.CancellationToken; + var config = await CreateEnabledOidcProviderAsync(autoCreate: true); + + // First login (JIT) with group A. + Guid userId; + using (var scope = Factory.Services.CreateScope()) + { + var processor = scope.ServiceProvider.GetRequiredService(); + var r1 = await processor.ProcessAsync( + BuildPrincipal("sub-rerun", "rerun@acme.com", ["GroupA"]), config.Id, ct); + Assert.True(r1.Succeeded); + userId = r1.UserId!.Value; + } + + // Second login (returning) with groups B + C — must REPLACE A. + using (var scope = Factory.Services.CreateScope()) + { + var processor = scope.ServiceProvider.GetRequiredService(); + var r2 = await processor.ProcessAsync( + BuildPrincipal("sub-rerun", "rerun@acme.com", ["GroupB", "GroupC"]), config.Id, ct); + Assert.True(r2.Succeeded); + Assert.Equal(userId, r2.UserId); + } + + await using var read = GetTenantedSession(); + var store = await read.LoadAsync(userId, ct); + Assert.NotNull(store); + var groups = store!.Claims.Where(e => e.Type == "groups").Select(e => e.Value).ToList(); + Assert.DoesNotContain("GroupA", groups); + Assert.Contains("GroupB", groups); + Assert.Contains("GroupC", groups); + } + + [Fact] + public async Task GdprErase_Scrubs_The_Claims_Store() + { + var ct = TestContext.Current.CancellationToken; + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Claims", lastname: "Gdpr", acronym: "CG", email: "claims-gdpr@test.com"); + + await SeedClaimsStoreAsync(user.Id); + + using (var scope = Factory.Services.CreateScope()) + { + var gdpr = scope.ServiceProvider.GetRequiredService(); + var result = await gdpr.PermanentlyEraseAsync( + user.Id, adminUserId: null, reason: "test-erase", ct); + Assert.False(result.IsError, result.IsError ? result.FirstError.Description : null); + } + + await using var read = GetTenantedSession(); + Assert.Null(await read.LoadAsync(user.Id, ct)); + } + + [Fact] + public async Task AdminDelete_Scrubs_The_Claims_Store() + { + var ct = TestContext.Current.CancellationToken; + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Claims", lastname: "Del", acronym: "CD", email: "claims-del@test.com"); + + await SeedClaimsStoreAsync(user.Id); + + var response = await Client.DeleteAsync($"/api/user/{new ShortGuid(user.Id)}", ct); + response.EnsureSuccessStatusCode(); + + await using var read = GetTenantedSession(); + Assert.Null(await read.LoadAsync(user.Id, ct)); + } + + private async Task SeedClaimsStoreAsync(Guid userId) + { + await using var seed = GetTenantedDocumentSession(); + seed.Store(new ExternalClaimsStore + { + Id = userId, + Claims = + [ + new ClaimEntry("provider:seed", "email", "claims-seed@test.com", DateTimeOffset.UtcNow), + new ClaimEntry("provider:seed", "groups", "Finance", DateTimeOffset.UtcNow), + ], + }); + await seed.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + private async Task CreateEnabledOidcProviderAsync(bool autoCreate) + { + var id = Guid.NewGuid(); + var slug = $"fed{Guid.NewGuid():N}"[..12]; + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + session.Events.StartStream(id, new LoginProviderAddedEvent( + Id: id, + Type: LoginProviderType.Oidc, + Flavor: LoginProviderFlavor.GenericOidc, + Slug: slug, + DisplayName: $"Fed_{Guid.NewGuid():N}"[..12], + Description: null, + IsBuiltIn: false, + Enabled: true, + ClientId: "client", + ClientSecretEncrypted: null, + Scopes: ["openid", "profile", "email"], + UserUpdateScript: "(claims) => ({ email: claims.email })", + StoreRawClaims: false, + RawClaimsRetentionDays: null, + AutoCreateUsers: autoCreate, + AllowLinking: true, + TrustForEmailLink: false, + AllowedEmailDomains: null, + IconName: null, + ButtonColorHex: null, + FlavorData: null, + CreatedAt: DateTimeOffset.UtcNow)); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return (await session.LoadAsync(id, TestContext.Current.CancellationToken))!; + } + + private static ClaimsPrincipal BuildPrincipal(string subject, string email, IReadOnlyList groups) + { + var identity = new ClaimsIdentity("oidc"); + identity.AddClaim(new Claim("iss", Issuer)); + identity.AddClaim(new Claim("sub", subject)); + identity.AddClaim(new Claim("email", email)); + foreach (var g in groups) + identity.AddClaim(new Claim("groups", g)); + return new ClaimsPrincipal(identity); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederationV1Phase3Tests.cs b/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederationV1Phase3Tests.cs new file mode 100644 index 00000000..cfe46744 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ExternalAuth/FederationV1Phase3Tests.cs @@ -0,0 +1,180 @@ +using System.Security.Claims; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authentication.Api.ExternalAuth; +using Modgud.Authentication.Domain; +using Modgud.Authentication.Domain.ExternalAuth; +using Modgud.Authentication.Domain.ExternalAuth.Events; +using Modgud.Authentication.Domain.LoginProviders; +using Modgud.Authentication.Domain.LoginProviders.Events; +using Modgud.Authorization.Apps; +using Modgud.Authorization.Events; +using Modgud.Authorization.Membership; +using Modgud.Authorization.Principals; +using Modgud.Domain.Users.Events; +using Modgud.Permissions.Abstractions; +using Marten; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Api.Tests.ExternalAuth; + +/// +/// Federation v1 — Phase 3 (login wiring: deriver bake-in + profile gate). +/// First behavior activation. Pins: a TrustForAuthorization provider bakes the +/// internal session-group claim for matched ExternallyDrivable groups; an +/// untrusted (or non-matching) login bakes none; and the AuthoritativeForProfile +/// gate (with the JIT-creator default) replaces the every-provider flapping. +/// +[Collection(IntegrationTestCollection.Name)] +public class FederationV1Phase3Tests : IntegrationTestBase +{ + public FederationV1Phase3Tests(SharedPostgresFixture fixture) : base(fixture) { } + + private const string Issuer = "https://idp.phase3.test/v2.0"; + private const string GroupScript = + "(p) => Type.Is(p, 'person') && p.IsActive && p.ExternalGroups.includes('entra-admins')"; + + [Fact] + public async Task TrustedProvider_Bakes_SessionGroup_Claim_For_Matched_Group() + { + var ct = TestContext.Current.CancellationToken; + var config = await CreateOidcProviderAsync(trustForAuthorization: true); + var user = await Factory.CreateTestUserWithIdentityAsync("Fed", "Trusted", "FT", "fed-trusted@acme.com"); + await LinkUserAsync(user.Id, config.Id, "sub-trusted"); + var group = await CreateExternallyDrivableGroupAsync(GroupScript); + + var result = await RunLoginAsync(config.Id, "sub-trusted", "fed-trusted@acme.com", ["entra-admins"]); + + Assert.True(result.Succeeded); + var sessionGroups = result.Principal!.FindAll(FederationClaimTypes.SessionGroup).Select(c => c.Value).ToList(); + Assert.Contains(group.ToString(), sessionGroups); + } + + [Fact] + public async Task UntrustedProvider_Bakes_No_SessionGroup_Claim() + { + var ct = TestContext.Current.CancellationToken; + var config = await CreateOidcProviderAsync(trustForAuthorization: false); + var user = await Factory.CreateTestUserWithIdentityAsync("Fed", "Untrusted", "FU", "fed-untrusted@acme.com"); + await LinkUserAsync(user.Id, config.Id, "sub-untrusted"); + await CreateExternallyDrivableGroupAsync(GroupScript); + + var result = await RunLoginAsync(config.Id, "sub-untrusted", "fed-untrusted@acme.com", ["entra-admins"]); + + Assert.True(result.Succeeded); + Assert.Empty(result.Principal!.FindAll(FederationClaimTypes.SessionGroup)); + } + + [Fact] + public async Task TrustedProvider_NonMatching_Groups_Bakes_No_Claim() + { + var config = await CreateOidcProviderAsync(trustForAuthorization: true); + var user = await Factory.CreateTestUserWithIdentityAsync("Fed", "Nomatch", "FN", "fed-nomatch@acme.com"); + await LinkUserAsync(user.Id, config.Id, "sub-nomatch"); + await CreateExternallyDrivableGroupAsync(GroupScript); + + var result = await RunLoginAsync(config.Id, "sub-nomatch", "fed-nomatch@acme.com", ["all-staff"]); + + Assert.True(result.Succeeded); + Assert.Empty(result.Principal!.FindAll(FederationClaimTypes.SessionGroup)); + } + + [Fact] + public async Task ProfileGate_NonAuthoritative_NonCreator_Does_Not_Patch_But_Creator_Does() + { + var ct = TestContext.Current.CancellationToken; + // A non-authoritative provider whose script would rename the user. + var config = await CreateOidcProviderAsync( + trustForAuthorization: false, + userUpdateScript: "(claims) => ({ firstname: claims.given_name })"); + + // User A: linked as a NON-creator → must NOT be patched (no flapping). + var userA = await Factory.CreateTestUserWithIdentityAsync("Original", "A", "OA", "gate-a@acme.com"); + await LinkUserAsync(userA.Id, config.Id, "sub-gate-a", isCreator: false); + await RunLoginAsync(config.Id, "sub-gate-a", "gate-a@acme.com", [], givenName: "Changed"); + + // User B: linked as the JIT CREATOR → stays profile-authoritative by default. + var userB = await Factory.CreateTestUserWithIdentityAsync("Original", "B", "OB", "gate-b@acme.com"); + await LinkUserAsync(userB.Id, config.Id, "sub-gate-b", isCreator: true); + await RunLoginAsync(config.Id, "sub-gate-b", "gate-b@acme.com", [], givenName: "Changed"); + + using var scope = Factory.Services.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var a = await users.FindByIdAsync(userA.Id.ToString()); + var b = await users.FindByIdAsync(userB.Id.ToString()); + Assert.Equal("Original", a!.Firstname); // gated off — not authoritative, not creator + Assert.Equal("Changed", b!.Firstname); // creator default authority patches + } + + // ── helpers ────────────────────────────────────────────────────────── + + private async Task RunLoginAsync( + Guid providerId, string subject, string email, IReadOnlyList groups, string? givenName = null) + { + using var scope = Factory.Services.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + return await processor.ProcessAsync( + BuildPrincipal(subject, email, groups, givenName), providerId, TestContext.Current.CancellationToken); + } + + private async Task CreateOidcProviderAsync( + bool trustForAuthorization, + string userUpdateScript = "(claims) => ({ email: claims.email })") + { + var id = Guid.NewGuid(); + var slug = $"fed{Guid.NewGuid():N}"[..12]; + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + session.Events.StartStream(id, new LoginProviderAddedEvent( + Id: id, Type: LoginProviderType.Oidc, Flavor: LoginProviderFlavor.GenericOidc, + Slug: slug, DisplayName: $"Fed_{Guid.NewGuid():N}"[..12], Description: null, + IsBuiltIn: false, Enabled: true, ClientId: "client", ClientSecretEncrypted: null, + Scopes: ["openid", "profile", "email"], UserUpdateScript: userUpdateScript, + StoreRawClaims: false, RawClaimsRetentionDays: null, + AutoCreateUsers: false, AllowLinking: true, TrustForEmailLink: false, + AllowedEmailDomains: null, IconName: null, ButtonColorHex: null, FlavorData: null, + CreatedAt: DateTimeOffset.UtcNow, + TrustForAuthorization: trustForAuthorization, AuthoritativeForProfile: false)); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return (await session.LoadAsync(id, TestContext.Current.CancellationToken))!; + } + + private async Task LinkUserAsync(Guid userId, Guid providerId, string subject, bool isCreator = false) + { + var linkId = Guid.NewGuid(); + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + session.Events.StartStream(linkId, new ExternalIdentityLinkedEvent( + linkId, userId, providerId, Issuer, subject, null, null, DateTimeOffset.UtcNow, IsCreator: isCreator)); + session.Events.Append(userId, new UserExternalIdentityLinkedEvent( + userId, linkId, providerId, Issuer, DateTimeOffset.UtcNow)); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + private async Task CreateExternallyDrivableGroupAsync(string script) + { + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + var evaluator = scope.ServiceProvider.GetRequiredService(); + var compiled = evaluator.TranspileMembershipScript(script); + var id = Guid.CreateVersion7(); + session.Events.StartStream(id, new GroupCreatedEvent( + id, $"Fed_{Guid.NewGuid():N}", null, [], [], + MembershipMode.Auto, script, compiled, null, null, EmailMode.Shared, + [AppSlugs.Modgud], ExternallyDrivable: true)); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return id; + } + + private static ClaimsPrincipal BuildPrincipal( + string subject, string email, IReadOnlyList groups, string? givenName) + { + var identity = new ClaimsIdentity("oidc"); + identity.AddClaim(new Claim("iss", Issuer)); + identity.AddClaim(new Claim("sub", subject)); + identity.AddClaim(new Claim("email", email)); + if (givenName is not null) identity.AddClaim(new Claim("given_name", givenName)); + foreach (var g in groups) identity.AddClaim(new Claim("groups", g)); + return new ClaimsPrincipal(identity); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs b/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs index bc16abb9..ef1e3d76 100644 --- a/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs +++ b/src/dotnet/Modgud.Api.Tests/Infrastructure/IntegrationTestBase.cs @@ -147,6 +147,15 @@ internal class CookieContainerHandler : DelegatingHandler { private readonly CookieContainer _cookies = new(); + /// + /// Pre-seed a cookie (e.g. a hand-forged auth cookie) so the very first + /// request already carries it. Used by federated-login tests that build the + /// ApplicationScheme cookie out-of-band instead of going through a real + /// upstream IdP round-trip. + /// + public void Seed(Uri uri, string name, string value) + => _cookies.Add(uri, new Cookie(name, value) { Path = "/" }); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // Add stored cookies to outgoing request diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs index f1a2142f..7bb10210 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs @@ -5,6 +5,7 @@ using Modgud.Authorization.Services; using Modgud.Domain.OAuth.Apis; using Modgud.Permissions; +using Modgud.Permissions.Abstractions; using Modgud.Domain.OAuth.Applications; using Modgud.Domain.OAuth.Consent; using Modgud.Domain.OAuth.Scopes; @@ -159,7 +160,9 @@ private static async Task AuthorizeAsync( if (consentType == ConsentTypes.Implicit || authorizations.Count != 0) { - var principal = await CreateClaimsPrincipalAsync(user, request, scopeManager, userManager: userManager); + var principal = await CreateClaimsPrincipalAsync( + user, request, scopeManager, userManager: userManager, + cookiePrincipal: authResult.Principal); var authorization = authorizations.LastOrDefault(); authorization ??= await authorizationManager.CreateAsync( @@ -297,9 +300,19 @@ private static async Task ExchangeAsync( } var originalScopes = result.Principal?.GetScopes(); - var principal = await CreateClaimsPrincipalAsync(user, request, scopeManager, originalScopes, userManager); + var principal = await CreateClaimsPrincipalAsync( + user, request, scopeManager, originalScopes, userManager, + cookiePrincipal: result.Principal); principal.SetAuthorizationId(result.Principal?.GetAuthorizationId()); + // Federation v1.1: bake the federated resource_access (durable ∪ + // session-derived) into the access token HERE, while the carrier is + // still on the principal. Needed for BOTH client types — OpenIddict + // strips the no-destination carrier from the access token (and from the + // reference payload), so it can't be read back at UserInfo for either. + await BakeFederatedResourceAccessAsync( + principal, user.Id, request, session, permissionService); + return Results.SignIn(principal, properties: null, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -378,6 +391,11 @@ private static async Task ExchangeAsync( identity.SetDestinations(static claim => claim.Type switch { Claims.Name or Claims.Subject => new[] { Destinations.AccessToken, Destinations.IdentityToken }, + // Hub-boundary defense-in-depth (decision D): the cc-flow never + // copies the session-group carrier (fresh identity, no cookie), but + // pin it to NO destination here too so any future drift that adds + // it can't fall through to the access-token default and leak. + FederationClaimTypes.SessionGroup => Array.Empty(), _ => new[] { Destinations.AccessToken }, }); @@ -534,10 +552,28 @@ private static async Task UserinfoAsync( var wantsPermissions = httpContext.User.HasScope("permissions"); var audiences = httpContext.User.GetAudiences().ToList(); - var resourceAccess = await BuildResourceAccessAsync( - user.Id, audiences, wantsRoles, wantsPermissions, session, permissionService); - if (resourceAccess is not null) - claims["resource_access"] = resourceAccess; + // Federation: the federated resource_access (durable ∪ session-derived) is + // baked into the access token at issuance for both client types (see + // BakeFederatedResourceAccessAsync) — OpenIddict strips the no-destination + // carrier from the access token, so it can't be recomputed from the carrier + // here. Echo the token's own block verbatim so UserInfo and the token agree. + // For a reference token the block lives in the server-side payload (opaque on + // the wire); for a JWT it rides the token. The recompute branch is a fallback + // for tokens that carry no baked block (e.g. minted before v1.1). + var bakedResourceAccess = httpContext.User.GetClaim("resource_access"); + if (!string.IsNullOrEmpty(bakedResourceAccess)) + { + claims["resource_access"] = + System.Text.Json.JsonSerializer.Deserialize(bakedResourceAccess); + } + else + { + var sessionGroupIds = ReadSessionGroupIds(httpContext.User); + var resourceAccess = await BuildResourceAccessAsync( + user.Id, audiences, wantsRoles, wantsPermissions, session, permissionService, sessionGroupIds); + if (resourceAccess is not null) + claims["resource_access"] = resourceAccess; + } return Results.Ok(claims); } @@ -559,10 +595,17 @@ private static async Task UserinfoAsync( bool wantsRoles, bool wantsPermissions, IDocumentSession session, - IPermissionService permissionService) + IPermissionService permissionService, + IReadOnlyCollection? sessionGroupIds = null) { if (!wantsRoles && !wantsPermissions) return null; + // Federation v1 (decision D): the single union call site. For human + // UserInfo this carries the session-derived group IDs read off the + // access-token principal; for the cc-flow + Service-Account paths it is + // empty, so the union overloads behave identically to the no-arg ones. + var sessionIds = sessionGroupIds ?? Array.Empty(); + var resourceAccess = new Dictionary(StringComparer.Ordinal); foreach (var audience in audiences) { @@ -577,7 +620,7 @@ private static async Task UserinfoAsync( if (wantsPermissions) { - var rawPermissions = await permissionService.GetUserPermissionsAsync(principalId, app.Slug); + var rawPermissions = await permissionService.GetUserPermissionsAsync(principalId, app.Slug, sessionIds); var expandedPermissions = ExpandBypassTiers(rawPermissions, app); var apiPermissions = NarrowToApiSubset(expandedPermissions, api, app); block["permissions"] = apiPermissions; @@ -585,7 +628,7 @@ private static async Task UserinfoAsync( if (wantsRoles) { - var rolesForApp = await permissionService.GetUserRolesAsync(principalId, app.Slug); + var rolesForApp = await permissionService.GetUserRolesAsync(principalId, app.Slug, sessionIds); block["roles"] = rolesForApp.Select(r => r.Name).ToArray(); } @@ -623,6 +666,15 @@ private static string[] ExpandBypassTiers(IReadOnlyList rawPermissions, var emit = new HashSet(StringComparer.Ordinal); + // Federation v1 invariant (decision G): realm:admin is hard local-only. + // This expander receives flat permission strings with no source tag, so it + // CANNOT distinguish a local realm:admin from an externally-derived one — + // a session-sourced realm:admin would expand the whole catalog here. The + // provenance-aware strip therefore lives upstream in + // PermissionService.GetUserPermissionsAsync (the union overload only adds + // realm:admin for durable groups); by the time a realm:admin string + // reaches this method it is guaranteed local. Do NOT relax that. + // // realm:admin trumps everything — emit the whole catalog and stop. if (rawPermissions.Contains(PermissionEvaluator.RealmAdminPermission)) { @@ -838,7 +890,8 @@ private static async Task CreateClaimsPrincipalAsync( OpenIddictRequest request, IOpenIddictScopeManager scopeManager, IEnumerable? scopeOverrides = null, - UserManager? userManager = null) + UserManager? userManager = null, + ClaimsPrincipal? cookiePrincipal = null) { // Identity must use the OpenIddict default authentication type so it processes // the claims correctly (Identity's ApplicationScheme identity is filtered out). @@ -889,6 +942,25 @@ private static async Task CreateClaimsPrincipalAsync( if (!string.IsNullOrEmpty(user.Lastname)) identity.SetClaim(Claims.FamilyName, user.Lastname); } + // Federation v1 (decision D/E): copy the session-group carrier claim(s) + // from the cookie/grant principal onto this grant. One claim per matched + // ExternallyDrivable group GUID. GetDestinations yields nothing for this + // type, so SetDestinations below leaves it with NO destination — it is + // persisted in the server-side reference token (read back at UserInfo and + // re-baked at refresh) but never emitted on the wire (hub boundary). + // • Authorize path passes the live cookie principal → reflects this + // login's freshly-derived groups. + // • Refresh/code/device path passes the rehydrated reference-token + // principal → re-copies the FROZEN set, no recompute. The session is + // the lease (decision E). + if (cookiePrincipal is not null) + { + foreach (var carrier in cookiePrincipal.FindAll(FederationClaimTypes.SessionGroup)) + { + identity.AddClaim(new Claim(FederationClaimTypes.SessionGroup, carrier.Value)); + } + } + principal.SetDestinations(GetDestinations); return principal; } @@ -896,9 +968,89 @@ private static async Task CreateClaimsPrincipalAsync( private static string GetDisplayName(ApplicationUser user) => AuthorizationEndpointHelpers.GetDisplayName(user); + /// + /// Federation v1.1 — bake the per-audience resource_access block (durable + /// ∪ session-derived) into the access token at issuance, for BOTH reference and + /// JWT clients. + /// + /// The session-group carrier is a no-destination claim, and OpenIddict's + /// PrepareAccessTokenPrincipal strips every no-destination claim before + /// building the access token — including the copy persisted with a reference + /// token. So the carrier is NOT readable back at UserInfo for reference clients + /// either (the original v1 "lazy recompute at UserInfo" assumption was wrong). + /// We therefore compute the union HERE, while the carrier is still on the + /// issuance principal, and embed only the RESULT — the permissions/roles the RS + /// is entitled to — as a normal (access-token-destined) claim: + /// + /// JWT clients: it rides the self-contained token (RS reads it directly). + /// Reference clients: it survives the strip (access-token destination), + /// is persisted in the server-side reference payload, stays opaque on the wire, + /// and is echoed at UserInfo. + /// + /// The carrier itself never gains a destination, so the hub boundary holds: only + /// the rendered result ever leaves, never the raw group IDs. + /// + /// Audiences come from the requested resource= indicators when + /// present — exactly what narrows the + /// token's aud to — so the baked blocks match the token's audience set and + /// never over-share. Consistent with decision E (the lease): the set is frozen + /// for the token's life and re-baked at refresh (durable re-read, session + /// re-copied frozen). Reference tokens additionally keep instant revocation. + /// + private static async Task BakeFederatedResourceAccessAsync( + ClaimsPrincipal principal, + Guid userId, + OpenIddictRequest request, + IDocumentSession session, + IPermissionService permissionService) + { + var wantsRoles = principal.HasScope(Scopes.Roles); + var wantsPermissions = principal.HasScope("permissions"); + if (!wantsRoles && !wantsPermissions) return; + + // The token's aud after ResourceIndicatorHandler == the requested + // resource= set (validated to be a subset of the granted resources); fall + // back to the scope-derived set when no indicator was sent. Either way this + // equals what lands on the token's aud, so the blocks won't over-share. + var requested = request.GetResources().ToList(); + var audiences = requested.Count > 0 ? requested : principal.GetResources().ToList(); + + var resourceAccess = await BuildResourceAccessAsync( + userId, audiences, wantsRoles, wantsPermissions, session, permissionService, + ReadSessionGroupIds(principal)); + if (resourceAccess is null) return; + + var identity = (ClaimsIdentity)principal.Identity!; + identity.SetClaim("resource_access", + System.Text.Json.JsonSerializer.SerializeToElement(resourceAccess)); + + // Re-stamp destinations so the freshly-added claim routes to the access + // token (GetDestinations default case). SetDestinations only stamps the + // claims present when it runs, and CreateClaimsPrincipalAsync already ran + // it before this claim existed. + principal.SetDestinations(GetDestinations); + } + private static IEnumerable GetDestinations(Claim claim) => AuthorizationEndpointHelpers.GetDestinations(claim); + /// + /// Federation v1 (decision D) — reads the internal modgud:session-group + /// carrier off a principal and parses each value to a group GUID. One claim + /// per group; malformed values are skipped defensively. Returns an empty set + /// when none are present (password / JWT-access / non-federated logins). + /// + private static IReadOnlyCollection ReadSessionGroupIds(ClaimsPrincipal principal) + { + List? ids = null; + foreach (var claim in principal.FindAll(FederationClaimTypes.SessionGroup)) + { + if (Guid.TryParse(claim.Value, out var id)) + (ids ??= []).Add(id); + } + return ids is null ? Array.Empty() : ids; + } + /// /// OAUTH-14 — read the cocoar:enabled property off the OAuth /// application. Missing → treated as enabled (matches the legacy @@ -1180,6 +1332,13 @@ public static IEnumerable GetDestinations(Claim claim) case "AspNet.Identity.SecurityStamp": yield break; + // Federation v1 (hub boundary, decision D): the session-group carrier + // is INTERNAL — it rides the server-side reference token and is unioned + // into resource_access at UserInfo/token time, but must NEVER reach the + // wire. Yield nothing for either token (exactly like SecurityStamp). + case FederationClaimTypes.SessionGroup: + yield break; + default: yield return Destinations.AccessToken; yield break; diff --git a/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs b/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs index 5dea6f13..70d047a5 100644 --- a/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Groups/GroupEndpoints.cs @@ -19,7 +19,8 @@ public record CreateGroupDto( string? MembershipScript = null, string? Email = null, EmailMode EmailMode = EmailMode.Shared, - List? BoundTo = null); + List? BoundTo = null, + bool ExternallyDrivable = false); public static class GroupEndpoints { @@ -151,7 +152,7 @@ object MapPrincipal(Principal p, string? viaId = null, string? viaName = null) dto.RoleIds.Select(r => new ShortGuid(r).Guid).ToList(), dto.MembershipMode, dto.MembershipScript, dto.Email, dto.EmailMode, - boundTo); + boundTo, dto.ExternallyDrivable); var result = await bus.InvokeAsync>(command); return result.Match( group => Results.Ok(MapToResponse(group)), @@ -170,7 +171,7 @@ object MapPrincipal(Principal p, string? viaId = null, string? viaName = null) dto.RoleIds.Select(r => new ShortGuid(r).Guid).ToList(), dto.MembershipMode, dto.MembershipScript, dto.Email, dto.EmailMode, - dto.BoundTo); + dto.BoundTo, dto.ExternallyDrivable); var result = await bus.InvokeAsync>(command); return result.Match( group => Results.Ok(MapToResponse(group)), @@ -209,5 +210,6 @@ object MapPrincipal(Principal p, string? viaId = null, string? viaName = null) g.Email, EmailMode = g.EmailMode.ToString(), g.BoundTo, + g.ExternallyDrivable, }; } diff --git a/src/dotnet/Modgud.Api/Features/Users/Commands/DeleteUsersCommand.cs b/src/dotnet/Modgud.Api/Features/Users/Commands/DeleteUsersCommand.cs index 80e762db..17137d10 100644 --- a/src/dotnet/Modgud.Api/Features/Users/Commands/DeleteUsersCommand.cs +++ b/src/dotnet/Modgud.Api/Features/Users/Commands/DeleteUsersCommand.cs @@ -63,6 +63,11 @@ public async Task> Handle( session.Store(appUser); } + // Federation v1: drop the per-user external-claims snapshot (plain + // doc, not event-sourced) so externally-derived authz can never + // outlive the user. Rides this same batched SaveChanges. + session.Delete(id); + // Hard-delete any external identity links owned by this user. Soft- // unlinking would leave tombstones occupying the (Issuer, Subject) // unique-index slot, blocking the same external identity from ever diff --git a/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs b/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs index d36b9cd6..efa0a4a2 100644 --- a/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Users/UsersEndpoints.cs @@ -401,7 +401,8 @@ public static WebApplication MapUsersEndpoints(this WebApplication application, group.MembershipMode, group.MembershipScript, group.CompiledMembershipScript, group.MembershipScriptDependencies, group.Email, group.EmailMode, - BoundTo: group.BoundTo)); + BoundTo: group.BoundTo, + ExternallyDrivable: group.ExternallyDrivable)); await session.SaveChangesAsync(); return Results.NoContent(); }) @@ -433,7 +434,8 @@ public static WebApplication MapUsersEndpoints(this WebApplication application, group.MembershipMode, group.MembershipScript, group.CompiledMembershipScript, group.MembershipScriptDependencies, group.Email, group.EmailMode, - BoundTo: group.BoundTo)); + BoundTo: group.BoundTo, + ExternallyDrivable: group.ExternallyDrivable)); await session.SaveChangesAsync(); return Results.NoContent(); }) diff --git a/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs b/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs index 59cf66ec..ae1c6781 100644 --- a/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs +++ b/src/dotnet/Modgud.Application/DTOs/LoginProviders/LoginProviderDto.cs @@ -41,6 +41,8 @@ public record LoginProviderDto public required bool AutoCreateUsers { get; init; } public required bool AllowLinking { get; init; } public required bool TrustForEmailLink { get; init; } + public required bool TrustForAuthorization { get; init; } + public required bool AuthoritativeForProfile { get; init; } public List? AllowedEmailDomains { get; init; } public string? IconName { get; init; } public string? ButtonColorHex { get; init; } diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs index 7cac3c1d..a0a7c52c 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/CreateLoginProviderCommand.cs @@ -45,7 +45,9 @@ public record CreateLoginProviderCommand( bool? TrustForEmailLink = null, List? AllowedEmailDomains = null, string? IconName = null, - string? ButtonColorHex = null); + string? ButtonColorHex = null, + bool? TrustForAuthorization = null, + bool? AuthoritativeForProfile = null); public class CreateLoginProviderHandler( IDocumentSession session, @@ -146,6 +148,8 @@ public async Task> Handle(CreateLoginProviderCommand comm AutoCreateUsers: command.AutoCreateUsers ?? false, AllowLinking: command.AllowLinking ?? true, TrustForEmailLink: command.TrustForEmailLink ?? false, + TrustForAuthorization: command.TrustForAuthorization ?? false, + AuthoritativeForProfile: command.AuthoritativeForProfile ?? false, AllowedEmailDomains: command.AllowedEmailDomains, IconName: command.IconName ?? flavor.DefaultIconName, ButtonColorHex: command.ButtonColorHex, @@ -229,6 +233,8 @@ private async Task> CreateSamlAsync( AutoCreateUsers: command.AutoCreateUsers ?? false, AllowLinking: command.AllowLinking ?? true, TrustForEmailLink: command.TrustForEmailLink ?? false, + TrustForAuthorization: command.TrustForAuthorization ?? false, + AuthoritativeForProfile: command.AuthoritativeForProfile ?? false, AllowedEmailDomains: command.AllowedEmailDomains, IconName: command.IconName ?? flavor.DefaultIconName, ButtonColorHex: command.ButtonColorHex, @@ -281,6 +287,10 @@ private async Task> CreateInternalAsync( AutoCreateUsers: false, AllowLinking: false, TrustForEmailLink: false, + // Internal provider is strictly local — never trusted for external + // authorization, never authoritative for profile. + TrustForAuthorization: false, + AuthoritativeForProfile: false, AllowedEmailDomains: null, IconName: null, ButtonColorHex: null, diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/UpdateLoginProviderCommand.cs b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/UpdateLoginProviderCommand.cs index e8aaa5c3..30e5b8a4 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/UpdateLoginProviderCommand.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/Commands/UpdateLoginProviderCommand.cs @@ -40,7 +40,11 @@ public record UpdateLoginProviderCommand( Optional IconName, Optional ButtonColorHex, Optional FlavorData, - Optional Enabled); + Optional Enabled, + // Federation v1. Defaulted to None so existing callers / tests are unchanged + // and an omitted field preserves the persisted value (PATCH semantics). + Optional TrustForAuthorization = default, + Optional AuthoritativeForProfile = default); public class UpdateLoginProviderHandler( IDocumentSession session, @@ -74,6 +78,8 @@ public async Task> Handle(UpdateLoginProviderCommand comm var autoCreate = command.AutoCreateUsers.OrDefault(config.AutoCreateUsers); var allowLinking = command.AllowLinking.OrDefault(config.AllowLinking); var trustForEmailLink = command.TrustForEmailLink.OrDefault(config.TrustForEmailLink); + var trustForAuthorization = command.TrustForAuthorization.OrDefault(config.TrustForAuthorization); + var authoritativeForProfile = command.AuthoritativeForProfile.OrDefault(config.AuthoritativeForProfile); var allowedEmailDomains = command.AllowedEmailDomains.HasValue ? command.AllowedEmailDomains.Value : config.AllowedEmailDomains; var iconName = command.IconName.HasValue ? command.IconName.Value : config.IconName; var buttonColorHex = command.ButtonColorHex.HasValue ? command.ButtonColorHex.Value : config.ButtonColorHex; @@ -133,6 +139,7 @@ public async Task> Handle(UpdateLoginProviderCommand comm || command.Scopes.HasValue || command.UserUpdateScript.HasValue || command.StoreRawClaims.HasValue || command.RawClaimsRetentionDays.HasValue || command.AutoCreateUsers.HasValue || command.AllowLinking.HasValue || command.TrustForEmailLink.HasValue + || command.TrustForAuthorization.HasValue || command.AuthoritativeForProfile.HasValue || command.AllowedEmailDomains.HasValue || command.IconName.HasValue || command.ButtonColorHex.HasValue || command.FlavorData.HasValue; @@ -150,6 +157,8 @@ public async Task> Handle(UpdateLoginProviderCommand comm AutoCreateUsers: autoCreate, AllowLinking: allowLinking, TrustForEmailLink: trustForEmailLink, + TrustForAuthorization: trustForAuthorization, + AuthoritativeForProfile: authoritativeForProfile, AllowedEmailDomains: allowedEmailDomains, IconName: iconName, ButtonColorHex: buttonColorHex, diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs index 12e00bdb..3a56debd 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/LoginProviders/LoginProvidersEndpoints.cs @@ -128,7 +128,9 @@ public static void MapLoginProvidersEndpoints(this IEndpointRouteBuilder endpoin TrustForEmailLink: request.TrustForEmailLink, AllowedEmailDomains: request.AllowedEmailDomains, IconName: request.IconName, - ButtonColorHex: request.ButtonColorHex); + ButtonColorHex: request.ButtonColorHex, + TrustForAuthorization: request.TrustForAuthorization, + AuthoritativeForProfile: request.AuthoritativeForProfile); var result = await bus.InvokeAsync>(command, ct); return result.Match( v => Results.Created($"/api/admin/login-providers/{v.Id:N}", ToDto(v, ResolvePublicUrl(conf))), @@ -163,7 +165,9 @@ public static void MapLoginProvidersEndpoints(this IEndpointRouteBuilder endpoin IconName: request.IconName, ButtonColorHex: request.ButtonColorHex, FlavorData: flavorData, - Enabled: request.Enabled); + Enabled: request.Enabled, + TrustForAuthorization: request.TrustForAuthorization, + AuthoritativeForProfile: request.AuthoritativeForProfile); var result = await bus.InvokeAsync>(command, ct); return result.Match( v => Results.Ok(ToDto(v, ResolvePublicUrl(conf))), @@ -221,6 +225,8 @@ private static string ResolvePublicUrl(IServerConfiguration conf) AutoCreateUsers = c.AutoCreateUsers, AllowLinking = c.AllowLinking, TrustForEmailLink = c.TrustForEmailLink, + TrustForAuthorization = c.TrustForAuthorization, + AuthoritativeForProfile = c.AuthoritativeForProfile, AllowedEmailDomains = c.AllowedEmailDomains, IconName = c.IconName, ButtonColorHex = c.ButtonColorHex, @@ -273,7 +279,9 @@ public record CreateLoginProviderRequest( bool? TrustForEmailLink = null, List? AllowedEmailDomains = null, string? IconName = null, - string? ButtonColorHex = null); + string? ButtonColorHex = null, + bool? TrustForAuthorization = null, + bool? AuthoritativeForProfile = null); // PATCH semantics: every field is Optional, so a caller can send just // the properties it wants to change (e.g. the grid sends only Enabled to @@ -293,7 +301,9 @@ public record UpdateLoginProviderRequest( Optional IconName, Optional ButtonColorHex, Optional FlavorData, - Optional Enabled); + Optional Enabled, + Optional TrustForAuthorization = default, + Optional AuthoritativeForProfile = default); public record RotateSecretRequest(string Secret); } diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs index 68405025..c3145470 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs @@ -10,8 +10,10 @@ using Modgud.Authentication.Domain.ExternalAuth.Events; using Modgud.Authentication.Domain.LoginProviders; using Modgud.Authorization.Principals; +using Modgud.Authorization.Services; using Modgud.Domain.Users.Events; using Modgud.Authentication.Identity.ExternalAuth; +using Modgud.Permissions.Abstractions; namespace Modgud.Authentication.Api.ExternalAuth; @@ -34,6 +36,7 @@ public class ExternalLoginProcessor( IDocumentSession session, UserManager userManager, UserUpdateScriptRunner scriptRunner, + ILoginTimeMembershipDeriver membershipDeriver, ILogger logger, TimeProvider clock) { @@ -86,6 +89,10 @@ public async Task ProcessAsync( var capturedAt = clock.GetUtcNow(); + // Federation v1: the current provider's groups claim drives the in-memory + // session membership derivation in Success() (always an array post-capture). + var externalGroups = ClaimValues(rawClaims, "groups"); + // 1. Existing link → happy path var link = await session.Query() .Where(l => l.Issuer == issuer && l.Subject == subject) @@ -132,14 +139,20 @@ public async Task ProcessAsync( } else { - // Apply user-update-script patches. Email-conflict is a hard reject. - var applyResult = await ApplyUserUpdatesAsync(user, scriptResult, ct); - if (applyResult is not null) - return ExternalLoginResult.Failed(applyResult.ErrorCode, applyResult.ErrorMessage); + // Apply user-update-script patches only when this provider is + // authoritative for the profile (decision A) — the existing link's + // IsCreator covers the JIT-creator default so a JIT-created user's + // profile isn't frozen. Email-conflict is a hard reject. + if (ShouldPatchProfile(config, link)) + { + var applyResult = await ApplyUserUpdatesAsync(user, scriptResult, ct); + if (applyResult is not null) + return ExternalLoginResult.Failed(applyResult.ErrorCode, applyResult.ErrorMessage); + } await RecordScriptRunAsync(link, config, scriptResult, rawClaims, capturedAt, ct); logger.LogInformation("Auth: External login (returning) user {UserId} via IdP {IdpId}", user.Id, loginProviderId); - return Success(user, link, externalPrincipal, loginProviderId, issuer); + return await Success(user, link, externalPrincipal, loginProviderId, issuer, config, externalGroups, ct); } } @@ -152,17 +165,22 @@ public async Task ProcessAsync( if (existing is null) return ExternalLoginResult.Failed("Idp.UserMissing", "Your account could not be loaded."); - var applyResult = await ApplyUserUpdatesAsync(existing, scriptResult, ct); - if (applyResult is not null) - return ExternalLoginResult.Failed(applyResult.ErrorCode, applyResult.ErrorMessage); + // New link to an existing account — this provider did not create the + // user, so it patches the profile only if explicitly authoritative. + if (ShouldPatchProfile(config, link: null)) + { + var applyResult = await ApplyUserUpdatesAsync(existing, scriptResult, ct); + if (applyResult is not null) + return ExternalLoginResult.Failed(applyResult.ErrorCode, applyResult.ErrorMessage); + } var addedLink = await CreateLinkAsync( existing.Id, loginProviderId, issuer, subject, scriptResult, rawClaims, - config.StoreRawClaims, capturedAt, ct); + config.StoreRawClaims, config.Slug, isCreator: false, capturedAt, ct); logger.LogInformation( "Auth: External identity linked to existing user {UserId} via IdP {IdpId}", existing.Id, loginProviderId); - return Success(existing, addedLink, externalPrincipal, loginProviderId, issuer); + return await Success(existing, addedLink, externalPrincipal, loginProviderId, issuer, config, externalGroups, ct); } // 2. No link — domain allowlist gate (use email from script output) @@ -188,14 +206,19 @@ public async Task ProcessAsync( var user = await userManager.FindByIdAsync(existing.Id.ToString()); if (user is not null) { - var applyResult = await ApplyUserUpdatesAsync(user, scriptResult, ct); - if (applyResult is not null) - return ExternalLoginResult.Failed(applyResult.ErrorCode, applyResult.ErrorMessage); + // New email-matched link to an existing account — not the + // creator, so patch the profile only if explicitly authoritative. + if (ShouldPatchProfile(config, link: null)) + { + var applyResult = await ApplyUserUpdatesAsync(user, scriptResult, ct); + if (applyResult is not null) + return ExternalLoginResult.Failed(applyResult.ErrorCode, applyResult.ErrorMessage); + } - var newLink = await CreateLinkAsync(user.Id, loginProviderId, issuer, subject, scriptResult, rawClaims, config.StoreRawClaims, capturedAt, ct); + var newLink = await CreateLinkAsync(user.Id, loginProviderId, issuer, subject, scriptResult, rawClaims, config.StoreRawClaims, config.Slug, isCreator: false, capturedAt, ct); logger.LogInformation( "Auth: External login (email-linked) user {UserId} via IdP {IdpId}", user.Id, loginProviderId); - return Success(user, newLink, externalPrincipal, loginProviderId, issuer); + return await Success(user, newLink, externalPrincipal, loginProviderId, issuer, config, externalGroups, ct); } } } @@ -231,9 +254,11 @@ public async Task ProcessAsync( if (created is null) return ExternalLoginResult.Failed("Idp.JitCreationFailed", "Could not create a new user account."); - var jitLink = await CreateLinkAsync(created.Id, loginProviderId, issuer, subject, scriptResult, rawClaims, config.StoreRawClaims, capturedAt, ct); + // This provider created the user — mark the link as the creator so it + // stays profile-authoritative by default (decision A). + var jitLink = await CreateLinkAsync(created.Id, loginProviderId, issuer, subject, scriptResult, rawClaims, config.StoreRawClaims, config.Slug, isCreator: true, capturedAt, ct); logger.LogInformation("Auth: External login (JIT-created) user {UserId} via IdP {IdpId}", created.Id, loginProviderId); - return Success(created, jitLink, externalPrincipal, loginProviderId, issuer); + return await Success(created, jitLink, externalPrincipal, loginProviderId, issuer, config, externalGroups, ct); } /// @@ -331,18 +356,24 @@ public async Task ProcessAsync( return null; } - private ExternalLoginResult Success( + private async Task Success( ApplicationUser user, ExternalIdentityLink link, ClaimsPrincipal external, Guid loginProviderId, - string issuer) + string issuer, + LoginProvider config, + IReadOnlyList externalGroups, + CancellationToken ct) { - // Build the sign-in ClaimsPrincipal. Post-refactor we carry only the - // minimum needed for session mechanics — link id + issuer for logout - // routing, amr for TwoFactorFederated. Groups/roles/email etc. are - // **not** on the session: persistent membership is the sole source of - // truth (see the IdP-authentication-only design note). + // Build the sign-in ClaimsPrincipal. It carries session mechanics — link + // id + issuer for logout routing, amr for TwoFactorFederated — PLUS, for a + // provider trusted for authorization, the federation v1 "session group" + // claims: one INTERNAL no-destination claim per ExternallyDrivable group + // this login matched. That claim is copied into the OpenIddict grant and + // unioned into resource_access at token time, but is NEVER emitted to the + // wire (the hub boundary). The session is the lease (decision D/E). Durable + // roles/permissions still resolve from persistent membership at token time. var identity = new ClaimsIdentity(IdentityConstants.ApplicationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); if (!string.IsNullOrWhiteSpace(user.UserName)) @@ -355,6 +386,23 @@ private ExternalLoginResult Success( foreach (var amr in external.FindAll("amr")) identity.AddClaim(new Claim("modgud.external.amr", amr.Value)); + // Federation v1: derive ExternallyDrivable group membership in-memory from + // (local ∪ this provider's claims), gated on the per-provider + // TrustForAuthorization opt-in. A password / untrusted-provider login + // carries none. realm:admin is never externally derivable (guarded both at + // config-write time and defensively inside the deriver). + if (config.TrustForAuthorization) + { + var derived = await membershipDeriver.DeriveAsync( + user.Id, externalGroups, $"provider:{config.Slug}", ct); + foreach (var groupId in derived.MatchedGroupIds) + identity.AddClaim(new Claim(FederationClaimTypes.SessionGroup, groupId.ToString())); + if (derived.MatchedGroupIds.Count > 0) + logger.LogInformation( + "Auth: external-derived grant — user {UserId} via IdP {IdpId} ({Slug}) matched {Count} session group(s)", + user.Id, loginProviderId, config.Slug, derived.MatchedGroupIds.Count); + } + return new ExternalLoginResult( Succeeded: true, UserId: user.Id, @@ -364,6 +412,23 @@ private ExternalLoginResult Success( ErrorMessage: null); } + /// + /// Federation v1 (decision A): does THIS provider write the four profile + /// fields on this login? True if it is explicitly authoritative, or — for the + /// returning-link path — if its link is the JIT creator's (the default + /// authority until an admin promotes another provider). New links (link-to- + /// authed / email-match) are not creators, so they patch only when explicitly + /// authoritative. Replaces the old every-provider-patches-every-login flapping. + /// + private static bool ShouldPatchProfile(LoginProvider config, ExternalIdentityLink? link) + => config.AuthoritativeForProfile || link is { IsCreator: true }; + + /// Reads a claim's values as a string array (scalar → one element, absent → empty). + private static string[] ClaimValues(IReadOnlyDictionary rawClaims, string type) + => rawClaims.TryGetValue(type, out var v) + ? v switch { string[] arr => arr, string s => [s], _ => [] } + : []; + private static bool IsEmailAllowed(LoginProvider config, string? email) { if (config.AllowedEmailDomains is null || config.AllowedEmailDomains.Count == 0) @@ -382,6 +447,8 @@ private async Task CreateLinkAsync( UserUpdateResult script, IReadOnlyDictionary rawClaims, bool storeRawClaims, + string providerSlug, + bool isCreator, DateTimeOffset capturedAt, CancellationToken ct) { @@ -397,7 +464,8 @@ private async Task CreateLinkAsync( Subject: subject, Email: email, DisplayName: displayName, - LinkedAt: capturedAt); + LinkedAt: capturedAt, + IsCreator: isCreator); session.Events.StartStream(linkId, linkedEvent); @@ -422,6 +490,10 @@ private async Task CreateLinkAsync( session.Events.Append(userId, new UserLoggedInEvent(userId, IpAddress: null)); + // Federation v1: refresh this provider's claims snapshot in the same + // transaction as the link write. + await StageClaimsStoreRefreshAsync(userId, providerSlug, rawClaims, capturedAt, ct); + await session.SaveChangesAsync(ct); // Reload the materialized link so the caller gets back the projected form. @@ -451,6 +523,10 @@ private async Task RecordScriptRunAsync( session.Events.Append(link.UserId, new UserLoggedInEvent(link.UserId, IpAddress: null)); + // Federation v1: refresh this provider's claims snapshot in the same + // transaction as the login write. + await StageClaimsStoreRefreshAsync(link.UserId, config.Slug, rawClaims, capturedAt, ct); + await session.SaveChangesAsync(ct); } @@ -503,6 +579,50 @@ private static JsonDocument SerializeRawClaims(IReadOnlyDictionary + /// Federation v1 — refreshes the current provider's slice of the per-user + /// (decision B): delete every entry tagged + /// provider:<slug>, then write the freshly-captured claims. Local + /// and other-provider entries are left untouched (SET/FORCE reconcile, one + /// provider only). Stages onto the session WITHOUT saving — the caller's + /// single SaveChangesAsync commits it atomically with the login write. + /// + private async Task StageClaimsStoreRefreshAsync( + Guid userId, + string providerSlug, + IReadOnlyDictionary rawClaims, + DateTimeOffset capturedAt, + CancellationToken ct) + { + var source = $"provider:{providerSlug}"; + var store = await session.LoadAsync(userId, ct) + ?? new ExternalClaimsStore { Id = userId }; + + store.Claims.RemoveAll(e => e.Source == source); + foreach (var (type, value) in rawClaims) + { + switch (value) + { + case string s: + store.Claims.Add(new ClaimEntry(source, type, s, capturedAt)); + break; + case string[] arr: + foreach (var v in arr) + store.Claims.Add(new ClaimEntry(source, type, v, capturedAt)); + break; + } + } + + session.Store(store); + } + + // Claim types that are semantically multi-valued and MUST stay arrays even + // when the IdP emits exactly one value — otherwise a single-group/role user + // collapses to a scalar string and a script doing `claims.groups.includes(...)` + // breaks (string.includes is substring-match). Federation v1, decision F/I15. + private static readonly HashSet AlwaysArrayClaims = + new(StringComparer.OrdinalIgnoreCase) { "groups", "roles", "amr" }; + private static IReadOnlyDictionary ExtractRawClaims(ClaimsPrincipal principal) { var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -515,11 +635,13 @@ private static JsonDocument SerializeRawClaims(IReadOnlyDictionary(StringComparer.OrdinalIgnoreCase); foreach (var (k, v) in dict) - result[k] = v.Count == 1 ? v[0] : v.ToArray(); + result[k] = v.Count == 1 && !AlwaysArrayClaims.Contains(k) ? v[0] : v.ToArray(); return result; } diff --git a/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/Events/ExternalIdentityLinkEvents.cs b/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/Events/ExternalIdentityLinkEvents.cs index 07688db6..b0049934 100644 --- a/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/Events/ExternalIdentityLinkEvents.cs +++ b/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/Events/ExternalIdentityLinkEvents.cs @@ -16,7 +16,10 @@ public record ExternalIdentityLinkedEvent( string Subject, string? Email, string? DisplayName, - DateTimeOffset LinkedAt); + DateTimeOffset LinkedAt, + // Federation v1 (decision A). Trailing optional so existing construction + // sites + old streams default to false (a non-creator link). + bool IsCreator = false); /// /// Recorded on every successful login via this link: a snapshot of the raw diff --git a/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/ExternalClaimsStore.cs b/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/ExternalClaimsStore.cs new file mode 100644 index 00000000..4f72d925 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/ExternalClaimsStore.cs @@ -0,0 +1,38 @@ +namespace Modgud.Authentication.Domain.ExternalAuth; + +/// +/// Federation v1 — per-user snapshot of the claims seen at login, tagged by +/// source. A plain Marten document (NOT event-sourced) keyed on the user id +/// ( == userId), mirroring UserDeletionState so it can be +/// Loaded / Deleted directly. +/// +/// On every successful external login the current provider's entries +/// (source=provider:<slug>) are delete+rewrite refreshed (SET/FORCE +/// reconcile); local + other-provider entries are left untouched. This is the +/// backing data for the in-memory, session-scoped membership derivation — never +/// the source of durable Group.MemberIds. +/// +/// +/// Refreshable snapshot, not an audit trail. It is scrubbed wholesale by a +/// plain Delete on user delete / GDPR erase — there is no event stream to +/// mask (masking rules apply to events only). +/// +/// +public class ExternalClaimsStore +{ + /// The Modgud user this snapshot belongs to (equals the user id). + public Guid Id { get; set; } + + public List Claims { get; set; } = []; +} + +/// +/// One captured claim value, tagged with its source. +/// +/// is "local" or "provider:<slug>" (the +/// immutable LoginProvider.Slug). is the login +/// timestamp — stored for what-if age and the v2 lease, but not enforced +/// as a drop-timer in v1 (the session is the lease). +/// +/// +public record ClaimEntry(string Source, string Type, string Value, DateTimeOffset CapturedAt); diff --git a/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/ExternalIdentityLink.cs b/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/ExternalIdentityLink.cs index 6053734a..c1acf552 100644 --- a/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/ExternalIdentityLink.cs +++ b/src/dotnet/Modgud.Authentication/Domain/ExternalAuth/ExternalIdentityLink.cs @@ -36,6 +36,14 @@ public class ExternalIdentityLink public DateTimeOffset LinkedAt { get; set; } public DateTimeOffset LastLoginAt { get; set; } + /// + /// True when this link's provider JIT-created the Modgud user (federation v1, + /// decision A). Used to resolve the "JIT creator is profile-authoritative by + /// default" fallback at profile-patch time, so a JIT-created user's profile is + /// not silently frozen when no provider is explicitly authoritative. + /// + public bool IsCreator { get; set; } + // ── Debugging snapshot of the last script run ───────────────────── // Exists purely for admin visibility (IdP-claims modal) and post-hoc // debugging. Not authoritative for anything — overwritten on every login. diff --git a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/Events/LoginProviderEvents.cs b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/Events/LoginProviderEvents.cs index 8564ce0c..a0c72989 100644 --- a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/Events/LoginProviderEvents.cs +++ b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/Events/LoginProviderEvents.cs @@ -34,7 +34,13 @@ public record LoginProviderAddedEvent( string? IconName, string? ButtonColorHex, JsonDocument? FlavorData, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + // Federation v1 (decisions G + A). Trailing optional params so existing + // construction sites + old event streams default to false (un-trusted, + // non-authoritative). Marten serializes by property name, so placement + // after CreatedAt is irrelevant for replay. + bool TrustForAuthorization = false, + bool AuthoritativeForProfile = false); /// /// Admin updates a login provider. Does NOT carry secret bytes — rotations @@ -58,7 +64,12 @@ public record LoginProviderUpdatedEvent( string? IconName, string? ButtonColorHex, JsonDocument? FlavorData, - DateTimeOffset UpdatedAt); + DateTimeOffset UpdatedAt, + // Federation v1 (decisions G + A). Full-replace event: the one production + // producer (UpdateLoginProviderCommand) passes the merged values; trailing + // defaults keep test/other callers compiling and replay fail-closed to false. + bool TrustForAuthorization = false, + bool AuthoritativeForProfile = false); /// /// Client-secret rotation. Encrypted bytes ride the event only because the diff --git a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs index 97959a47..91067770 100644 --- a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs +++ b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs @@ -116,6 +116,24 @@ public class LoginProvider /// public bool TrustForEmailLink { get; set; } + /// + /// Federation v1 (decision G): when true, this provider's claims may + /// drive app:admin-and-below group membership at login (gated further + /// by per-group ). Mirror of + /// . realm:admin is never externally + /// drivable regardless of this flag. Default false. + /// + public bool TrustForAuthorization { get; set; } + + /// + /// Federation v1 (decision A): only providers flagged authoritative write the + /// four profile fields (Firstname/Lastname/Email/Acronym) on login, ending + /// today's every-provider-patches-every-login flapping. Default false; + /// the JIT-creating provider stays authoritative for its users until an admin + /// promotes another (resolved at profile-patch time, not on this document). + /// + public bool AuthoritativeForProfile { get; set; } + /// Optional email-domain allowlist (e.g. ["acme.com"]). null = no filter. public List? AllowedEmailDomains { get; set; } diff --git a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs index 4731ab33..9984c18d 100644 --- a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs +++ b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs @@ -239,6 +239,11 @@ private async Task> PerformPermanentEraseAsync(Guid userId, Guid? session.DeleteWhere(s => s.UserId == userId); session.Delete(userId); + // Federation v1: the per-user external-claims snapshot is a plain + // (non-event-sourced) doc keyed on the user id — a straight Delete + // fully erases its PII (no stream to mask). Rides this same batch. + session.Delete(userId); + // External identity links carry Email, DisplayName, and the raw IdP // claim payload on their OWN streams (keyed by link id). Drop the // projection doc here; the PII-bearing events are masked + archived diff --git a/src/dotnet/Modgud.Authentication/Identity/ExternalAuth/ExternalIdentityLinkProjection.cs b/src/dotnet/Modgud.Authentication/Identity/ExternalAuth/ExternalIdentityLinkProjection.cs index 68e37c47..fa93bb37 100644 --- a/src/dotnet/Modgud.Authentication/Identity/ExternalAuth/ExternalIdentityLinkProjection.cs +++ b/src/dotnet/Modgud.Authentication/Identity/ExternalAuth/ExternalIdentityLinkProjection.cs @@ -22,6 +22,7 @@ public partial class ExternalIdentityLinkProjection : SingleStreamProjection /// Map of SAML AuthnContextClassRef URIs to AMR (Authentication Method - /// Reference) values to stamp onto the Modgud session principal. Mirrors the - /// OIDC amr-claim preservation pattern — values flow into Modgud's - /// federated-MFA detection. + /// Reference) values, mirroring the OIDC amr-claim preservation pattern. /// + /// + /// Parsed but not yet consumed (federation v1, I15). This map is + /// configured, serialized/round-tripped, and seeded by the EntraID/ADFS flavor + /// presets, but no code path currently reads it to stamp amr onto the + /// Modgud session principal — SAML AMR→amr wiring is deferred. The OIDC + /// side already preserves amr from the external ticket + /// (ExternalLoginProcessor.Success); the SAML equivalent that maps + /// AuthnContextClassRef through this dictionary is a follow-up. See + /// dev-docs/future-features/saml-amr-wiring.md. + /// public IReadOnlyDictionary> AmrMapping { get; init; } = FrozenDictionary>.Empty; diff --git a/src/dotnet/Modgud.Authentication/Setup/LoginProviderRealmSeeder.cs b/src/dotnet/Modgud.Authentication/Setup/LoginProviderRealmSeeder.cs index 4b841361..b07ba78c 100644 --- a/src/dotnet/Modgud.Authentication/Setup/LoginProviderRealmSeeder.cs +++ b/src/dotnet/Modgud.Authentication/Setup/LoginProviderRealmSeeder.cs @@ -55,6 +55,8 @@ public async Task SeedAsync(string tenantId, ILogger? logger = null, Cancellatio AutoCreateUsers: false, AllowLinking: false, TrustForEmailLink: false, + TrustForAuthorization: false, + AuthoritativeForProfile: false, AllowedEmailDomains: null, IconName: null, ButtonColorHex: null, diff --git a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs index 2fc13566..e47b013e 100644 --- a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs +++ b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs @@ -81,6 +81,13 @@ public static StoreOptions UseModgudAuthentication(this StoreOptions options) options.Schema.For() .Identity(x => x.Id); + // Federation v1: per-user claims-per-source snapshot (refreshable, NOT + // event-sourced). Keyed on the user id like UserDeletionState so it can + // be Loaded/Deleted directly. Scrubbed by a plain Delete on user delete + // / GDPR erase — there is no stream to mask, so NO event-masking rule. + options.Schema.For() + .Identity(x => x.Id); + options.Schema.For() .Identity(x => x.Id) .Index(x => x.Type) diff --git a/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs b/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs index 4e44442a..ec599a8b 100644 --- a/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs +++ b/src/dotnet/Modgud.Authentication/Setup/RealmAdminBootstrapper.cs @@ -246,7 +246,8 @@ Guid CatalogId(string resource, string action) existingGroup.Id, existingGroup.Name, existingGroup.Description, newMemberIds, existingGroup.RoleIds, Email: existingGroup.Email, - BoundTo: existingGroup.BoundTo)); + BoundTo: existingGroup.BoundTo, + ExternallyDrivable: existingGroup.ExternallyDrivable)); } } } diff --git a/src/dotnet/Modgud.Authorization/Commands/CreateGroupCommand.cs b/src/dotnet/Modgud.Authorization/Commands/CreateGroupCommand.cs index 8e1e3816..60d41321 100644 --- a/src/dotnet/Modgud.Authorization/Commands/CreateGroupCommand.cs +++ b/src/dotnet/Modgud.Authorization/Commands/CreateGroupCommand.cs @@ -16,7 +16,8 @@ public record CreateGroupCommand( string? MembershipScript = null, string? Email = null, EmailMode EmailMode = EmailMode.Shared, - List? BoundTo = null); + List? BoundTo = null, + bool ExternallyDrivable = false); public class CreateGroupHandler( IDocumentSession session, @@ -38,6 +39,17 @@ public async Task> Handle( return Error.Conflict("Group.NameTaken", $"A group with the name '{normalized}' already exists."); + // Federation v1 (decision G): realm:admin is hard local-only. A group + // that confers realm:admin can never be externally drivable, because + // external claims are untrusted input. Enforced bidirectionally at the + // create/update seam where RoleIds and ExternallyDrivable are set together. + if (command.ExternallyDrivable) + { + var guardError = await GroupMembershipGuards.RejectIfConfersRealmAdminAsync( + session, command.RoleIds, ct); + if (guardError is not null) return guardError.Value; + } + string? compiledMembership = null; List? membershipDeps = null; if (command.MembershipMode == MembershipMode.Auto) @@ -87,6 +99,7 @@ public async Task> Handle( Email = command.Email, EmailMode = command.EmailMode, BoundTo = command.BoundTo?.ToList() ?? [], + ExternallyDrivable = command.ExternallyDrivable, }; session.Events.StartStream(group.Id, @@ -95,7 +108,7 @@ public async Task> Handle( group.MembershipMode, group.MembershipScript, group.CompiledMembershipScript, group.MembershipScriptDependencies, group.Email, group.EmailMode, - group.BoundTo)); + group.BoundTo, group.ExternallyDrivable)); if (group.MembershipMode == MembershipMode.Auto) await recalculator.RecalculateForGroupAsync(group, session, ct); diff --git a/src/dotnet/Modgud.Authorization/Commands/GroupMembershipGuards.cs b/src/dotnet/Modgud.Authorization/Commands/GroupMembershipGuards.cs new file mode 100644 index 00000000..b7623270 --- /dev/null +++ b/src/dotnet/Modgud.Authorization/Commands/GroupMembershipGuards.cs @@ -0,0 +1,38 @@ +using Modgud.Authorization.Roles; +using ErrorOr; +using Marten; + +namespace Modgud.Authorization.Commands; + +/// +/// Shared write-time guards for group commands. Keeps the federation v1 +/// realm:admin-local-only invariant in one place so create and update +/// enforce it identically. +/// +internal static class GroupMembershipGuards +{ + /// + /// Federation v1 (decision G): a group whose roles confer realm:admin + /// can never be marked . + /// Returns a validation error if any of belongs to + /// a role with IsRealmAdmin; otherwise null. + /// + public static async Task RejectIfConfersRealmAdminAsync( + IDocumentSession session, + IReadOnlyCollection roleIds, + CancellationToken ct) + { + if (roleIds.Count == 0) return null; + + var ids = roleIds.ToList(); + var confersRealmAdmin = await session.Query() + .Where(r => ids.Contains(r.Id) && r.IsRealmAdmin) + .AnyAsync(ct); + + if (confersRealmAdmin) + return Error.Validation("Group.ExternallyDrivableRealmAdmin", + "A group that confers realm:admin cannot be externally drivable — external claims are untrusted input."); + + return null; + } +} diff --git a/src/dotnet/Modgud.Authorization/Commands/UpdateGroupCommand.cs b/src/dotnet/Modgud.Authorization/Commands/UpdateGroupCommand.cs index dcc1fe3e..c7c25c64 100644 --- a/src/dotnet/Modgud.Authorization/Commands/UpdateGroupCommand.cs +++ b/src/dotnet/Modgud.Authorization/Commands/UpdateGroupCommand.cs @@ -18,7 +18,8 @@ public record UpdateGroupCommand( string? MembershipScript = null, string? Email = null, EmailMode EmailMode = EmailMode.Shared, - List? BoundTo = null); + List? BoundTo = null, + bool ExternallyDrivable = false); public class UpdateGroupHandler( IDocumentSession session, @@ -64,6 +65,17 @@ public async Task> Handle( $"Adding group {cycleMembers[0]} as a member would create a cycle."); } + // Federation v1 (decision G): realm:admin is hard local-only. Block + // marking a realm:admin-conferring group ExternallyDrivable. Bidirectional + // by construction — RoleIds and ExternallyDrivable arrive together here, + // so this also rejects adding a realm:admin role to a drivable group. + if (command.ExternallyDrivable) + { + var guardError = await GroupMembershipGuards.RejectIfConfersRealmAdminAsync( + session, command.RoleIds, ct); + if (guardError is not null) return guardError.Value; + } + string? compiledMembership = null; List? membershipDeps = null; if (command.MembershipMode == MembershipMode.Auto) @@ -102,7 +114,7 @@ public async Task> Handle( command.MembershipMode, command.MembershipScript, compiledMembership, membershipDeps, command.Email, command.EmailMode, - boundTo)); + boundTo, command.ExternallyDrivable)); if (command.MembershipMode == MembershipMode.Auto) { @@ -120,6 +132,7 @@ public async Task> Handle( Email = command.Email, EmailMode = command.EmailMode, BoundTo = boundTo, + ExternallyDrivable = command.ExternallyDrivable, }; await recalculator.RecalculateForGroupAsync(updatedGroup, session, ct); } diff --git a/src/dotnet/Modgud.Authorization/Events/GroupEvents.cs b/src/dotnet/Modgud.Authorization/Events/GroupEvents.cs index a154bd6e..db732964 100644 --- a/src/dotnet/Modgud.Authorization/Events/GroupEvents.cs +++ b/src/dotnet/Modgud.Authorization/Events/GroupEvents.cs @@ -14,7 +14,10 @@ public record GroupCreatedEvent( List? MembershipScriptDependencies = null, string? Email = null, EmailMode EmailMode = EmailMode.Shared, - List? BoundTo = null); + List? BoundTo = null, + // Federation v1 (decision G). Trailing optional param so existing positional + // construction sites are unaffected; old streams replay to default false. + bool ExternallyDrivable = false); public record GroupUpdatedEvent( Guid Id, @@ -28,7 +31,10 @@ public record GroupUpdatedEvent( List? MembershipScriptDependencies = null, string? Email = null, EmailMode EmailMode = EmailMode.Shared, - List? BoundTo = null); + List? BoundTo = null, + // Full-replace event: every producer MUST pass the current value or it resets + // to false. Trailing optional keeps positional callers compiling. + bool ExternallyDrivable = false); public record GroupMembershipRecomputedEvent( Guid Id, diff --git a/src/dotnet/Modgud.Authorization/Membership/AutoMembershipRecalculator.cs b/src/dotnet/Modgud.Authorization/Membership/AutoMembershipRecalculator.cs index e1dd3aee..bc4b284f 100644 --- a/src/dotnet/Modgud.Authorization/Membership/AutoMembershipRecalculator.cs +++ b/src/dotnet/Modgud.Authorization/Membership/AutoMembershipRecalculator.cs @@ -44,8 +44,12 @@ public async Task RecalculateForPrincipalAsync( { var principal = await session.LoadAsync(principalId, ct); + // Federation v1: ExternallyDrivable groups are computed in-memory at login + // only (their scripts read ExternalGroups, which has no JSONB column). + // Exclude them from the durable per-principal recompute so external claims + // never drive persisted MemberIds (the two-layer source filter). var groups = await session.Query() - .Where(g => !g.IsDeleted && g.MembershipMode == MembershipMode.Auto) + .Where(g => !g.IsDeleted && g.MembershipMode == MembershipMode.Auto && !g.ExternallyDrivable) .ToListAsync(ct); foreach (var group in groups) @@ -88,6 +92,12 @@ public async Task RecalculateForGroupAsync(Group group, IDocumentSession session { if (group.MembershipMode != MembershipMode.Auto) return; + // Federation v1: never run an ExternallyDrivable group's script through the + // JSONB batch — it reads ExternalGroups (no persisted column) and would + // translate to a non-existent path. Its membership is session-only, + // computed in-memory by LoginTimeMembershipDeriver. + if (group.ExternallyDrivable) return; + if (string.IsNullOrWhiteSpace(group.CompiledMembershipScript)) { if (group.MemberIds.Count > 0) @@ -162,20 +172,8 @@ public async Task RemoveUserFromAllAutoGroupsAsync(Guid principalId, IDocumentSe } private bool EvaluateSafe(string compiledScript, Principal principal, CancellationToken ct) - { - try - { - var compiled = evaluator.BuildPredicate(compiledScript, ct).Compile(); - return compiled(principal); - } - catch (Exception ex) - { - // NullReferenceException (e.g. `p.Email.endsWith(...)` when Email is null) - // treated as "not a member" — the safe default. - logger.LogWarning(ex, "Membership predicate threw for principal {PrincipalId}", principal.Id); - return false; - } - } + => MembershipPredicateEvaluation.EvaluateSafe( + evaluator, compiledScript, principal, logger, principal.Id, ct); private static bool SameSet(List a, List b) => a.Count == b.Count && a.ToHashSet().SetEquals(b); diff --git a/src/dotnet/Modgud.Authorization/Membership/EvalPrincipal.cs b/src/dotnet/Modgud.Authorization/Membership/EvalPrincipal.cs new file mode 100644 index 00000000..27cb2bd1 --- /dev/null +++ b/src/dotnet/Modgud.Authorization/Membership/EvalPrincipal.cs @@ -0,0 +1,40 @@ +using Modgud.Authorization.Principals; + +namespace Modgud.Authorization.Membership; + +/// +/// Federation v1 — the in-memory-only principal a login-time membership script +/// binds to (decision C/F). It is a transient with the +/// ephemeral, session-scoped external surface ( / +/// ) overlaid; it exists only for the current login. +/// +/// It derives from on purpose. Empirically, +/// Type.Is(p, 'person') compiles to a CLR type check against the +/// discriminator-registered type — a non-Principal wrapper returns +/// false and no person script ever matches. Deriving from +/// makes Type.Is narrow correctly and inherits the local fields so the +/// same script classifies identically on both engines (two-engine parity). +/// +/// +/// NEVER persist this type. It is deliberately NOT registered with Marten +/// (AddSubClass) or STJ (JsonDerivedType) — a session.Store +/// would land it in mt_doc_evalprincipal and corrupt the principal table +/// (the subclass double-registration trap). It is constructed only by +/// LoginTimeMembershipDeriver and evaluated only as the generic +/// TPrincipal of an in-memory compiled predicate over an +/// IQuerySession — the persisted JSONB-batch path never sees it. +/// +/// +public sealed class EvalPrincipal : Person +{ + /// + /// The current provider's groups claim values (source = local ∪ + /// provider:<current>), always an array so a script can do + /// p.ExternalGroups.includes('...') regardless of count. This is the + /// canonical federation membership signal ("is the user in upstream group X"). + /// + public string[] ExternalGroups { get; init; } = []; + + /// Source tag of the current login ("provider:<slug>"). + public string Source { get; init; } = ""; +} diff --git a/src/dotnet/Modgud.Authorization/Membership/MembershipPredicateEvaluation.cs b/src/dotnet/Modgud.Authorization/Membership/MembershipPredicateEvaluation.cs new file mode 100644 index 00000000..61fa5f63 --- /dev/null +++ b/src/dotnet/Modgud.Authorization/Membership/MembershipPredicateEvaluation.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; + +namespace Modgud.Authorization.Membership; + +/// +/// The single canonical in-memory membership evaluation: compile the predicate +/// and invoke it, treating ANY throw (notably a NullReferenceException from a +/// missing field, e.g. p.Email.endsWith(...) when Email is null) as +/// "not a member" — the safe default. +/// +/// Both in-memory consumers — the durable per-principal recalculator and the +/// federation login-time deriver — call this so the swallow-semantics and the +/// two-engine parity surface live in ONE place, not two copies. +/// +/// +internal static class MembershipPredicateEvaluation +{ + public static bool EvaluateSafe( + IMembershipEvaluator evaluator, + string compiledScript, + TPrincipal principal, + ILogger logger, + Guid principalId, + CancellationToken ct) + { + try + { + var compiled = evaluator.BuildPredicate(compiledScript, ct).Compile(); + return compiled(principal); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Membership predicate threw for principal {PrincipalId}", principalId); + return false; + } + } +} diff --git a/src/dotnet/Modgud.Authorization/Principals/Group.cs b/src/dotnet/Modgud.Authorization/Principals/Group.cs index ac7bc658..7405624d 100644 --- a/src/dotnet/Modgud.Authorization/Principals/Group.cs +++ b/src/dotnet/Modgud.Authorization/Principals/Group.cs @@ -52,6 +52,20 @@ public class Group : Principal, IPrincipalWithMembers, IPrincipalEmailAddressabl public string? MembershipScript { get; set; } public string? CompiledMembershipScript { get; set; } + /// + /// Federation v1 (decision G): opt-in marking this group eligible to receive + /// externally-derived membership at login time, computed in-memory from + /// the current provider's claims (never written to ). + /// Orthogonal to — a group can carry durable + /// local/auto members AND accept live-session external additions. + /// + /// A group whose roles confer realm:admin can NEVER be set + /// (bidirectional config guard) — external + /// claims are untrusted input. Default false. + /// + /// + public bool ExternallyDrivable { get; set; } + /// /// Dotted property paths the membership script reads from the principal /// directory (e.g. "Person.Firstname", "Email"). The auto-membership diff --git a/src/dotnet/Modgud.Authorization/Projections/PrincipalProjectionBase.cs b/src/dotnet/Modgud.Authorization/Projections/PrincipalProjectionBase.cs index 2aa9802a..87c00dbe 100644 --- a/src/dotnet/Modgud.Authorization/Projections/PrincipalProjectionBase.cs +++ b/src/dotnet/Modgud.Authorization/Projections/PrincipalProjectionBase.cs @@ -27,6 +27,7 @@ public abstract partial class PrincipalProjectionBase : SingleStreamProjection

Task> GetDescendantGroupIdsAsync(Guid groupId, CancellationToken ct = default); + + // ── Federation v1 union overloads (decision D) ──────────────────────── + // These add the live-session, externally-derived group set on top of the + // durable membership. are the + // ExternallyDrivable group IDs matched at login (carried on the sign-in + // cookie → OpenIddict grant as the no-destination "modgud:session-group" + // claim) and re-discovered at token/UserInfo time. They are unioned with + // the durable BFS result, their ancestors walked too (a session child still + // confers its parents' roles), and tagged with provenance so a + // session-sourced group can NEVER confer realm:admin (hard local-only, + // decision G). These are deliberately distinct OVERLOADS — not optional + // params on the methods above — so the non-OAuth call sites stay unchanged + // and the one union call site (BuildResourceAccessAsync) is greppable (I8). + + ///

+ /// As , plus the + /// session-derived and their ancestors. + /// + Task> GetUserGroupsAsync( + Guid userId, IReadOnlyCollection sessionGroupIds, CancellationToken ct = default); + + /// + /// As , + /// unioning the session-derived (and their + /// ancestors). The synthetic realm:admin entry is added ONLY for roles + /// reached through a durable (source=local) group — never from a session + /// source. <app>:admin and below may be externally driven and are + /// emitted regardless of provenance. + /// + Task> GetUserPermissionsAsync( + Guid userId, string appSlug, IReadOnlyCollection sessionGroupIds, CancellationToken ct = default); + + /// + /// As , + /// unioning the session-derived (and their + /// ancestors). A realm-admin role is returned ONLY when reached durably; + /// app-scoped roles are provenance-agnostic. + /// + Task> GetUserRolesAsync( + Guid userId, string appSlug, IReadOnlyCollection sessionGroupIds, CancellationToken ct = default); } diff --git a/src/dotnet/Modgud.Authorization/Services/LoginTimeMembershipDeriver.cs b/src/dotnet/Modgud.Authorization/Services/LoginTimeMembershipDeriver.cs new file mode 100644 index 00000000..f4d8aee4 --- /dev/null +++ b/src/dotnet/Modgud.Authorization/Services/LoginTimeMembershipDeriver.cs @@ -0,0 +1,124 @@ +using Modgud.Authorization.Membership; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Roles; +using Marten; +using Microsoft.Extensions.Logging; + +namespace Modgud.Authorization.Services; + +/// +/// Federation v1 (decision C) — computes a login's ephemeral, session-scoped +/// group membership from the current provider's claims, WITHOUT touching durable +/// Group.MemberIds. Read-only by construction (an , +/// no session.Events / SaveChanges): it can never persist membership +/// and never appends GroupMembershipRecomputedEvent. +/// +/// Modeled on , but it evaluates ONLY +/// MembershipMode==Auto + Group.ExternallyDrivable groups, binds the +/// predicate to an in-memory (local Person fields ∪ the +/// current provider's groups), and reuses the shared +/// so it agrees with the +/// durable engine on null/case/collation. +/// +/// +public interface ILoginTimeMembershipDeriver +{ + /// + /// Returns the group IDs the user matches this login via externally-drivable + /// scripts. is the current provider's + /// groups claim (already trust-gated by the caller); + /// is the "provider:<slug>" tag. realm:admin-conferring groups are + /// defensively excluded (the config guard should already make that impossible). + /// + Task DeriveAsync( + Guid principalId, + IReadOnlyList externalGroups, + string source, + CancellationToken ct = default); +} + +public sealed record DerivedMembershipResult(IReadOnlyList MatchedGroupIds) +{ + public static readonly DerivedMembershipResult Empty = new([]); +} + +public sealed class LoginTimeMembershipDeriver( + IQuerySession session, + IMembershipEvaluator evaluator, + ILogger logger) : ILoginTimeMembershipDeriver +{ + public async Task DeriveAsync( + Guid principalId, + IReadOnlyList externalGroups, + string source, + CancellationToken ct = default) + { + var person = await session.LoadAsync(principalId, ct); + if (person is null || person.IsDeleted || !person.IsActive) + return DerivedMembershipResult.Empty; + + // Hydrate the in-memory eval principal IDENTICALLY to the persisted Person + // (same null/case/collation) and overlay the current provider's groups. + var eval = new EvalPrincipal + { + Id = person.Id, + IsActive = person.IsActive, + IsDeleted = person.IsDeleted, + AccountName = person.AccountName, + Firstname = person.Firstname, + Lastname = person.Lastname, + Acronym = person.Acronym, + Email = person.Email, + NormalizedUserName = person.NormalizedUserName, + NormalizedEmail = person.NormalizedEmail, + ExternalIdentities = person.ExternalIdentities, + ExternalGroups = [.. externalGroups], + Source = source, + }; + + var drivable = await session.Query() + .Where(g => !g.IsDeleted && g.MembershipMode == MembershipMode.Auto && g.ExternallyDrivable) + .ToListAsync(ct); + + var matched = new List(); + foreach (var g in drivable) + { + if (string.IsNullOrWhiteSpace(g.CompiledMembershipScript)) continue; + if (g.Id == principalId) continue; // a group never lists itself + if (MembershipPredicateEvaluation.EvaluateSafe( + evaluator, g.CompiledMembershipScript!, eval, logger, principalId, ct)) + matched.Add(g); + } + + if (matched.Count == 0) return DerivedMembershipResult.Empty; + + // Belt-and-braces: realm:admin is hard local-only (decision G). The + // write-time config guard should already forbid an ExternallyDrivable + // group from conferring realm:admin — defensively drop any that slipped + // through (e.g. a role flipped IsRealmAdmin after the group was marked). + var safe = await StripRealmAdminConferringAsync(matched, ct); + return new DerivedMembershipResult([.. safe.Select(g => g.Id)]); + } + + private async Task> StripRealmAdminConferringAsync( + List groups, CancellationToken ct) + { + var roleIds = groups.SelectMany(g => g.RoleIds).Distinct().ToList(); + if (roleIds.Count == 0) return groups; + + var realmAdminRoleIds = (await session.Query() + .Where(r => roleIds.Contains(r.Id) && r.IsRealmAdmin) + .ToListAsync(ct)) + .Select(r => r.Id) + .ToHashSet(); + if (realmAdminRoleIds.Count == 0) return groups; + + var safe = groups.Where(g => !g.RoleIds.Any(realmAdminRoleIds.Contains)).ToList(); + var dropped = groups.Count - safe.Count; + if (dropped > 0) + logger.LogWarning( + "Auth: dropped {Count} externally-derived group(s) conferring realm:admin (config guard should have prevented this)", + dropped); + return safe; + } +} diff --git a/src/dotnet/Modgud.Authorization/Services/PermissionService.cs b/src/dotnet/Modgud.Authorization/Services/PermissionService.cs index fd3cf32c..c26a91ec 100644 --- a/src/dotnet/Modgud.Authorization/Services/PermissionService.cs +++ b/src/dotnet/Modgud.Authorization/Services/PermissionService.cs @@ -154,6 +154,205 @@ public async Task> GetUserRolesAsync(Guid userId, string ap .ToList(); } + // ── Federation v1 union overloads (decision D) ──────────────────────── + + public async Task> GetUserGroupsAsync( + Guid userId, IReadOnlyCollection sessionGroupIds, CancellationToken ct = default) + { + var resolved = await ResolveGroupsWithProvenanceAsync(userId, sessionGroupIds, ct); + return resolved.Select(r => r.Group).ToList(); + } + + public async Task> GetUserPermissionsAsync( + Guid userId, string appSlug, IReadOnlyCollection sessionGroupIds, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(appSlug); + + var resolved = await ResolveGroupsWithProvenanceAsync(userId, sessionGroupIds, ct); + if (resolved.Count == 0) return []; + + var activeGroups = resolved + .Where(r => r.Group.BoundTo.Contains(AllAppsWildcard) || r.Group.BoundTo.Contains(appSlug)) + .ToList(); + if (activeGroups.Count == 0) return []; + + // Role IDs reachable through a DURABLE (source=local) active group — the + // only roles allowed to confer realm:admin. A session-sourced group, even + // one whose ancestor carries a realm-admin role, must never grant it: + // realm:admin is hard local-only (decision G). The write-time config guard + // (a realm:admin-conferring group cannot be ExternallyDrivable) is the + // first line; this provenance check is the second. :admin and below + // MAY be externally driven, so the catalog-FK grants below stay + // provenance-agnostic. + var localRoleIds = activeGroups + .Where(r => r.IsLocal) + .SelectMany(r => r.Group.RoleIds) + .ToHashSet(); + + var roleIds = activeGroups.SelectMany(r => r.Group.RoleIds).Distinct().ToArray(); + if (roleIds.Length == 0) return []; + + var roles = await session.Query() + .Where(r => r.Id.IsOneOf(roleIds) && !r.IsDeleted) + .ToListAsync(ct); + + var requestedApp = await session.Query() + .FirstOrDefaultAsync(a => a.Slug == appSlug && !a.IsDeleted, ct); + var requestedAppId = requestedApp?.Id; + var requestedCatalog = requestedApp is null + ? new Dictionary() + : requestedApp.Permissions.ToDictionary(p => p.Id); + + var permissions = new HashSet(); + foreach (var role in roles) + { + // PROVENANCE-AWARE realm:admin strip (federation decision G): emit the + // synthetic realm:admin marker only when the realm-admin role is held + // through a durable group. localRoleIds == all roleIds when there are + // no session groups, so the non-federation path is unchanged. + if (role.IsRealmAdmin && localRoleIds.Contains(role.Id)) + { + permissions.Add(PermissionEvaluator.RealmAdminPermission); + } + + if (role.AppId.HasValue && role.AppId == requestedAppId) + { + foreach (var permissionId in role.PermissionIds) + { + if (requestedCatalog.TryGetValue(permissionId, out var catalogEntry)) + { + permissions.Add(catalogEntry.ToPermissionString()); + } + } + } + } + return permissions.ToList(); + } + + public async Task> GetUserRolesAsync( + Guid userId, string appSlug, IReadOnlyCollection sessionGroupIds, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(appSlug); + + var resolved = await ResolveGroupsWithProvenanceAsync(userId, sessionGroupIds, ct); + var activeGroups = resolved + .Where(r => r.Group.BoundTo.Contains(AllAppsWildcard) || r.Group.BoundTo.Contains(appSlug)) + .ToList(); + var roleIds = activeGroups.SelectMany(r => r.Group.RoleIds).Distinct().ToList(); + if (roleIds.Count == 0) + return []; + + var localRoleIds = activeGroups + .Where(r => r.IsLocal) + .SelectMany(r => r.Group.RoleIds) + .ToHashSet(); + + var requestedApp = await session.Query() + .FirstOrDefaultAsync(a => a.Slug == appSlug && !a.IsDeleted, ct); + var requestedAppId = requestedApp?.Id; + + var roles = await session.Query() + .Where(r => r.Id.IsOneOf(roleIds.ToArray()) && !r.IsDeleted) + .ToListAsync(ct); + + // A realm-admin role travels everywhere — but only when reached durably + // (provenance guard, mirroring GetUserPermissionsAsync). App-scoped roles + // are provenance-agnostic (externally drivable below realm level). + return roles + .Where(r => r.IsRealmAdmin + ? localRoleIds.Contains(r.Id) + : (requestedAppId.HasValue && r.AppId == requestedAppId)) + .ToList(); + } + + /// + /// Federation v1 (decision D) — resolves the user's effective group set with + /// per-group provenance, unioning durable membership with the session-derived + /// . + /// + /// Durable (source=local): the BFS ancestors of + /// — identical to + /// . + /// Session: each supplied group id (the membership itself) PLUS + /// its BFS ancestors — a session child still confers its parents' roles. + /// + /// A group reachable both ways is tagged = + /// true (durable wins), so a legitimately-held realm:admin is never + /// stripped. With an empty the result is + /// exactly the durable set, all local — keeping the no-arg methods' behavior. + /// + private async Task> ResolveGroupsWithProvenanceAsync( + Guid userId, IReadOnlyCollection sessionGroupIds, CancellationToken ct) + { + var allGroups = (await session.Query() + .Where(g => !g.IsDeleted) + .ToListAsync(ct)).ToList(); + + var byId = allGroups.ToDictionary(g => g.Id); + var parentMap = BuildParentMap(allGroups); + + // Durable pass: the user is not itself a group, so the seed is not + // collected — only its ancestors (mirrors GetUserGroupsAsync(userId)). + var local = new Dictionary(); + WalkAncestors([userId], includeSeeds: false, parentMap, byId, local); + + // Session pass: each matched session group IS a membership, so collect the + // seed groups themselves and then walk their ancestors. + var sessionResolved = new Dictionary(); + if (sessionGroupIds.Count > 0) + WalkAncestors(sessionGroupIds, includeSeeds: true, parentMap, byId, sessionResolved); + + var resolved = new List(local.Count + sessionResolved.Count); + foreach (var g in local.Values) + resolved.Add(new ResolvedGroup(g, IsLocal: true)); + foreach (var (id, g) in sessionResolved) + if (!local.ContainsKey(id)) + resolved.Add(new ResolvedGroup(g, IsLocal: false)); + return resolved; + } + + /// + /// BFS up the member-of graph from , collecting each + /// reached group into . When + /// is true the seed groups themselves are + /// collected (the user is a direct member of them); otherwise only their + /// ancestors are. + /// + private static void WalkAncestors( + IEnumerable seeds, + bool includeSeeds, + Dictionary> parentMap, + Dictionary byId, + Dictionary into) + { + var visited = new HashSet(); + var queue = new Queue(); + foreach (var seed in seeds) + { + if (!visited.Add(seed)) continue; + queue.Enqueue(seed); + if (includeSeeds && byId.TryGetValue(seed, out var seedGroup)) + into[seed] = seedGroup; + } + + while (queue.Count > 0) + { + var currentId = queue.Dequeue(); + if (!parentMap.TryGetValue(currentId, out var parents)) continue; + + foreach (var parent in parents) + { + if (visited.Add(parent.Id)) + { + into[parent.Id] = parent; + queue.Enqueue(parent.Id); + } + } + } + } + + private readonly record struct ResolvedGroup(Group Group, bool IsLocal); + public async Task> GetDescendantGroupIdsAsync(Guid groupId, CancellationToken ct = default) { var allGroups = (await session.Query() diff --git a/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs b/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs index 38e1bfec..7a5a883f 100644 --- a/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs +++ b/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ public static IServiceCollection AddModgudAuthorization( services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs index dc907974..d658f227 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs +++ b/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs @@ -8,9 +8,9 @@ namespace Modgud.Client.AspNetCore; /// /// Pre-request claims-transformation that flattens /// resource_access[] from -/// the principal's claims into flat , -/// "permission" and "group" claims so downstream gates work -/// without per-endpoint plumbing. +/// the principal's claims into flat and +/// "permission" claims so downstream gates work without per-endpoint +/// plumbing. /// /// Source of the data: the JWT-bearer middleware populates /// resource_access as a string-typed claim when configured with @@ -32,7 +32,20 @@ public sealed class ModgudClaimsTransformation : IClaimsTransformation /// Claim type for permission strings ("<resource>:<action>"). public const string PermissionClaimType = "permission"; - /// Claim type for group names. + /// + /// Claim type that USED to carry flattened group names. + /// + /// + /// Quarantined in federation v1 (hub boundary): the Modgud IdP never emits a + /// groups block in resource_access — group membership is purely + /// IdP-internal and is expanded into roles/permissions before emission. This + /// transformer therefore never produces a claim of this type. The constant is + /// retained for binary compatibility and will be removed in a future major + /// version. Gate on roles/permissions instead. + /// + [Obsolete("Hub boundary: the Modgud IdP never emits groups in resource_access, " + + "so no claim of this type is ever produced. Gate on roles/permissions instead. " + + "Retained for binary compatibility; removed in a future major version.")] public const string GroupClaimType = "group"; /// The standard OIDC/Keycloak UserInfo claim that nests per-RS authz info. @@ -68,7 +81,10 @@ public Task TransformAsync(ClaimsPrincipal principal) FlattenStringArray(identity, audienceBlock, "roles", ClaimTypes.Role); FlattenStringArray(identity, audienceBlock, "permissions", PermissionClaimType); - FlattenGroupObjectArray(identity, audienceBlock); + // Federation v1 hub boundary: the IdP never emits a "groups" block here + // (group membership is IdP-internal, expanded into roles/permissions before + // emission), so there is nothing to flatten. The legacy group flattener was + // removed; GroupClaimType is retained [Obsolete] for binary compatibility. return Task.FromResult(principal); } @@ -97,33 +113,6 @@ private static void FlattenStringArray( } } - /// - /// Groups arrive as [{ "id": "...", "name": "..." }] objects. - /// Flatten the name field into "group" claims (the id is - /// kept on the original resource_access string claim if a - /// caller needs it — flattening just one field per object is the - /// standard pattern). - /// - private static void FlattenGroupObjectArray(ClaimsIdentity identity, JsonElement audienceBlock) - { - if (!audienceBlock.TryGetProperty("groups", out var array) || - array.ValueKind != JsonValueKind.Array) - return; - - var existing = new HashSet( - identity.FindAll(GroupClaimType).Select(c => c.Value), - StringComparer.Ordinal); - - foreach (var element in array.EnumerateArray()) - { - if (element.ValueKind != JsonValueKind.Object) continue; - if (!element.TryGetProperty("name", out var nameElement)) continue; - var name = nameElement.GetString(); - if (string.IsNullOrEmpty(name) || !existing.Add(name)) continue; - identity.AddClaim(new Claim(GroupClaimType, name)); - } - } - private static bool TryParseJson(string raw, out JsonElement element) { try diff --git a/src/dotnet/Modgud.Client.AspNetCore/README.md b/src/dotnet/Modgud.Client.AspNetCore/README.md index 6bb79ad9..c2f24d40 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/README.md +++ b/src/dotnet/Modgud.Client.AspNetCore/README.md @@ -69,8 +69,7 @@ The IdP emits permissions per audience in Keycloak shape: "resource_access": { "event-tree-api": { "roles": ["Editor", "Viewer"], - "permissions": ["calendar:read", "calendar:write"], - "group": ["Calendar Team"] + "permissions": ["calendar:read", "calendar:write"] } } ``` @@ -81,7 +80,11 @@ The IdP emits permissions per audience in Keycloak shape: | --- | --- | | `roles` | `ClaimTypes.Role` | | `permissions` | `"permission"` | -| `group` | `"group"` | + +> Groups are deliberately **not** emitted by the IdP (hub boundary): group +> membership is IdP-internal and is expanded into roles/permissions before +> emission. The `GroupClaimType` constant and the old `groups` flattener are +> retained only for binary compatibility and are `[Obsolete]`. Read them with standard claims APIs: diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/RealmTokenValidationHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/RealmTokenValidationHandler.cs index 19f6a155..a1a08a72 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/RealmTokenValidationHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/RealmTokenValidationHandler.cs @@ -14,10 +14,25 @@ namespace Modgud.Infrastructure.OpenIddict; /// point of having per-realm keys in the first place. /// /// -/// Runs only for JWT-format tokens (access tokens for clients with -/// AccessTokenType.Jwt). Reference tokens go through a separate -/// store-lookup path that's already realm-isolated by virtue of the -/// per-tenant Marten store. +/// The discriminator is the token TYPE, mirroring +/// : only access_token and +/// id_token are signed with the realm key, so only their signatures +/// must be validated against it. Authorization codes, refresh tokens and +/// device codes are signed with the global pool and validated by the IdP +/// itself — we leave their key set untouched. +/// +/// +/// +/// This is NOT a reference-vs-JWT distinction. With +/// UseReferenceAccessTokens() an access token is delivered to the +/// client as an opaque reference, but its payload is still persisted as a +/// realm-signed JWT and re-validated on the way through +/// /connect/userinfo + /connect/introspect. An earlier version +/// keyed off IsReferenceToken and skipped exactly this case, leaving +/// the global keys in place → invalid_token (OpenIddict ID2090, +/// "signing key not found"). Reference REFRESH tokens, by contrast, stay +/// global-signed, so their access_token-free ValidTokenTypes +/// correctly falls through the guard. /// /// /// @@ -46,14 +61,39 @@ public RealmTokenValidationHandler(IRealmKeyStore keyStore) _keyStore = keyStore; } + // RFC 8693 token-type URIs OpenIddict stamps on tokens it issues. Only + // these two are signed with the realm key (see RealmSigningKeyHandler), + // so only their validation may install realm-only verification keys. + private const string AccessTokenType = "urn:ietf:params:oauth:token-type:access_token"; + private const string IdTokenType = "urn:ietf:params:oauth:token-type:id_token"; + public async ValueTask HandleAsync(ValidateTokenContext context) { ArgumentNullException.ThrowIfNull(context); - // Skip non-JWT formats (reference tokens, etc.) — the JWT signature - // pipeline doesn't run for them. if (context.TokenValidationParameters is null) return; - if (context.IsReferenceToken) return; + + // Install realm-only verification keys ONLY when an access token or + // id token is among the acceptable types for this validation: + // - /connect/userinfo accepts {access_token} + // - /connect/introspect, /connect/token, /connect/revoke accept the + // full generic set (which includes access_token) + // - identity-token validation accepts {id_token} + // Endpoints that accept NEITHER (e.g. a {client_assertion}-only + // validation, whose JWS is signed by the CLIENT's key — not the realm + // key) are left with their stock key set. + // + // This is deliberately keyed on token TYPE, not on IsReferenceToken. + // With UseReferenceAccessTokens the access token is delivered as an + // opaque reference, but ValidateReferenceTokenIdentifier swaps in its + // realm-signed JWT payload, which ValidateIdentityModelToken then + // verifies against these keys. The previous IsReferenceToken guard + // skipped that case → ID2090 at userinfo + introspect. + if (!context.ValidTokenTypes.Contains(AccessTokenType) && + !context.ValidTokenTypes.Contains(IdTokenType)) + { + return; + } var slug = TenantContext.Current; var keys = await _keyStore.GetVerificationKeysAsync(slug); diff --git a/src/dotnet/Modgud.Permissions.Abstractions/FederationClaimTypes.cs b/src/dotnet/Modgud.Permissions.Abstractions/FederationClaimTypes.cs new file mode 100644 index 00000000..814ee9cb --- /dev/null +++ b/src/dotnet/Modgud.Permissions.Abstractions/FederationClaimTypes.cs @@ -0,0 +1,19 @@ +namespace Modgud.Permissions.Abstractions; + +/// +/// Internal claim types for federation v1. Shared here so the issuer +/// (ExternalLoginProcessor, Authentication slice), the carrier copy +/// (AuthorizationEndpoints, API), and the union point (PermissionService, +/// Authorization slice) all reference the SAME literal. +/// +public static class FederationClaimTypes +{ + /// + /// Carries a session-derived ExternallyDrivable group GUID. One claim per + /// group. Set on the sign-in cookie (ExternalLoginProcessor.Success), copied + /// into the OpenIddict grant with NO destination, and unioned into + /// resource_access at token/UserInfo time — NEVER emitted to the wire (the + /// hub boundary). The session is the lease (decision D/E). + /// + public const string SessionGroup = "modgud:session-group"; +} diff --git a/src/dotnet/Modgud.Tests.Unit/Api/Features/Auth/OAuth/AuthorizationEndpointHelpersTests.cs b/src/dotnet/Modgud.Tests.Unit/Api/Features/Auth/OAuth/AuthorizationEndpointHelpersTests.cs index 4cc3978c..4e9cac3c 100644 --- a/src/dotnet/Modgud.Tests.Unit/Api/Features/Auth/OAuth/AuthorizationEndpointHelpersTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Api/Features/Auth/OAuth/AuthorizationEndpointHelpersTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Modgud.Api.Features.Auth.OAuth; using Modgud.Authentication.Domain; +using Modgud.Permissions.Abstractions; using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -206,6 +207,20 @@ public void SecurityStamp_claim_is_suppressed_from_both_tokens() Assert.Empty(AuthorizationEndpointHelpers.GetDestinations(c)); } + [Fact] + public void SessionGroup_carrier_claim_is_suppressed_from_both_tokens() + { + // Federation v1 hub boundary (decision D): the internal + // "modgud:session-group" carrier rides the server-side reference token + // and is unioned into resource_access at UserInfo/token time, but must + // NEVER reach the wire — like SecurityStamp, it yields no destination + // even with every scope granted. Without this case it would fall to the + // default branch and leak a group GUID into the access token. + var c = ClaimWithScopes(FederationClaimTypes.SessionGroup, Guid.NewGuid().ToString(), + Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles); + Assert.Empty(AuthorizationEndpointHelpers.GetDestinations(c)); + } + [Fact] public void Unknown_claim_types_default_to_access_token_only() { diff --git a/src/dotnet/Modgud.Tests.Unit/Authorization/EvalPrincipalMembershipTests.cs b/src/dotnet/Modgud.Tests.Unit/Authorization/EvalPrincipalMembershipTests.cs new file mode 100644 index 00000000..4e52758b --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Authorization/EvalPrincipalMembershipTests.cs @@ -0,0 +1,92 @@ +using Modgud.Authorization.Membership; +using Modgud.Authorization.Principals; +using Cocoar.JsEval.Engine; +using Cocoar.JsEval.Linq; +using Cocoar.JsEval.TsDefinition; +using Cocoar.JsEval.TypeScript; +using Microsoft.Extensions.DependencyInjection; + +namespace Modgud.Tests.Unit.Authorization; + +/// +/// Federation v1 — de-risk: can a membership predicate compiled over the +/// in-memory wrapper (NOT a Principal subclass) +/// translate + evaluate the patterns the v1 script contract needs? +/// Type.Is narrowing, local-field reads, and the ephemeral external surface +/// (ExternalGroups array .includes, ExternalClaims dictionary access). +/// +public sealed class EvalPrincipalMembershipTests : IDisposable +{ + private readonly ServiceProvider _sp; + private readonly IServiceScope _scope; + private readonly IMembershipEvaluator _evaluator; + private readonly TsTranspiler _transpiler; + + public EvalPrincipalMembershipTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddJsEval(b => b + .AddLinq() + .AddDiscriminatorMappings("Type", + ("person", typeof(Person)), + ("group", typeof(Group)), + ("service-account", typeof(ServiceAccount))) + .WithExecutionTimeout(TimeSpan.FromSeconds(2))); + services.AddTsTranspiler(); + services.AddTsDefinition(); + services.AddScoped(); + + _sp = services.BuildServiceProvider(); + _scope = _sp.CreateScope(); + _evaluator = _scope.ServiceProvider.GetRequiredService(); + _transpiler = _scope.ServiceProvider.GetRequiredService(); + } + + public void Dispose() + { + _scope.Dispose(); + _sp.Dispose(); + } + + private Func Compile(string ts) + { + var compiled = _transpiler.Transpile(ts); + return _evaluator.BuildPredicate(compiled).Compile(); + } + + [Fact] + public void TypeIs_Person_Narrows_Against_Wrapper() + { + var fn = Compile("(p: any) => Type.Is(p, 'person')"); + Assert.True(fn(new EvalPrincipal { Id = Guid.NewGuid() })); + } + + [Fact] + public void LocalField_Read_Works_Like_Person() + { + var fn = Compile("(p: any) => Type.Is(p, 'person') && p.Email != null && p.Email.endsWith('@acme.com')"); + Assert.True(fn(new EvalPrincipal { Email = "x@acme.com" })); + Assert.False(fn(new EvalPrincipal { Email = "x@contoso.com" })); + Assert.False(fn(new EvalPrincipal { Email = null })); + } + + [Fact] + public void ExternalGroups_Array_Includes_Works() + { + var fn = Compile("(p: any) => p.ExternalGroups.includes('Admins')"); + Assert.True(fn(new EvalPrincipal { ExternalGroups = ["IT", "Admins"] })); + Assert.False(fn(new EvalPrincipal { ExternalGroups = ["IT"] })); + Assert.False(fn(new EvalPrincipal { ExternalGroups = [] })); + } + + [Fact] + public void Realistic_Federation_Script_Type_And_Group() + { + // The canonical v1 federation rule: a person in the upstream group. + var fn = Compile("(p: any) => Type.Is(p, 'person') && p.IsActive && p.ExternalGroups.includes('entra-admins')"); + Assert.True(fn(new EvalPrincipal { IsActive = true, ExternalGroups = ["entra-admins", "all-staff"] })); + Assert.False(fn(new EvalPrincipal { IsActive = true, ExternalGroups = ["all-staff"] })); + Assert.False(fn(new EvalPrincipal { IsActive = false, ExternalGroups = ["entra-admins"] })); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs index 54380bb7..981f728b 100644 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs @@ -9,7 +9,8 @@ namespace Modgud.Tests.Unit.Client.AspNetCore; /// resource_access claim that the JWT-bearer middleware populated /// (from the JWT itself or via UserInfo) and projects the configured /// audience's block onto the principal as flat ClaimTypes.Role / -/// "permission" / "group" claims. +/// "permission" claims. Groups are NEVER flattened — the IdP never emits +/// a groups block (hub boundary, federation v1). /// /// The IdP pre-expands bypass tiers, so the lib is a pure /// claims-flattener — no HTTP, no cache, no evaluator. @@ -116,9 +117,12 @@ public async Task Flattens_audience_block_permissions_into_permission_claims() public class Groups { [Fact] - public async Task Flattens_audience_block_groups_into_group_claims() + public async Task Groups_block_is_never_flattened_hub_boundary() { - // Groups arrive as objects with id+name; we flatten the name. + // Federation v1 hub boundary: the Modgud IdP never emits a "groups" + // block in resource_access (membership is IdP-internal, expanded into + // roles/permissions before emission). Even if some upstream put one + // there, the transformer must NOT surface "group" claims. var resourceAccess = $$""" { "{{Audience}}": { @@ -135,39 +139,9 @@ public async Task Flattens_audience_block_groups_into_group_claims() var transformed = await NewSubject().TransformAsync(principal); - var groups = transformed - .FindAll(ModgudClaimsTransformation.GroupClaimType) - .Select(c => c.Value) - .ToList(); - Assert.Equal(2, groups.Count); - Assert.Contains("DevOps", groups); - Assert.Contains("Mitarbeiter", groups); - } - - [Fact] - public async Task Group_objects_without_name_are_skipped() - { - // Defensive: malformed entries don't crash mid-request. - var resourceAccess = $$""" - { - "{{Audience}}": { - "groups": [ - { "id": "g-1" }, - { "id": "g-2", "name": "OK" } - ] - } - } - """; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var groups = transformed - .FindAll(ModgudClaimsTransformation.GroupClaimType) - .Select(c => c.Value) - .ToList(); - Assert.Single(groups); - Assert.Contains("OK", groups); + // "group" is the quarantined GroupClaimType value — assert via the + // literal so the test itself doesn't reference the [Obsolete] symbol. + Assert.Empty(transformed.FindAll("group")); } } diff --git a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs index 518f0c3f..704bafc3 100644 --- a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs +++ b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs @@ -92,15 +92,14 @@ scopes = user.FindAll("scope").Select(c => c.Value) .Concat(user.FindAll("scp").Select(c => c.Value)) .ToArray(), - // Roles + permissions + groups come from the lib's claims-transformation, - // which read resource_access[] off the principal. They will - // be empty if the IdP hasn't emitted a block for this audience (e.g. - // because the user has no grants in the linked App). + // Roles + permissions come from the lib's claims-transformation, which + // reads resource_access[] off the principal. They will be empty + // if the IdP hasn't emitted a block for this audience (e.g. because the + // user has no grants in the linked App). Groups are never emitted by the + // IdP (hub boundary, federation v1) — there is no "groups" key to read. roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(), permissions = user.FindAll(ModgudClaimsTransformation.PermissionClaimType) .Select(c => c.Value).ToArray(), - groups = user.FindAll(ModgudClaimsTransformation.GroupClaimType) - .Select(c => c.Value).ToArray(), claims = user.Claims.Select(c => new { c.Type, c.Value }).ToArray() })).RequireAuthorization(); diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 402412c6..9ca7beb8 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -551,6 +551,13 @@ "noMembersYet": "Noch keine Mitglieder — werden nach dem Speichern berechnet.", "lastError": "Script-Fehler bei letzter Ausführung" }, + "externallyDrivable": { + "label": "Föderation", + "toggle": "Extern treibbar (Föderation)", + "hint": "Wenn aktiv, verleiht ein vertrauenswürdiger föderierter Login, dessen Membership-Script matcht, diese Gruppe nur für die jeweilige Session (nie in die dauerhaften Mitglieder geschrieben, nie realm:admin).", + "autoOnly": "Nur automatische Gruppen können extern getrieben werden — stelle den Typ zuerst auf Automatisch.", + "realmAdminBlocked": "Deaktiviert: diese Gruppe verleiht realm:admin, das niemals extern getrieben werden kann (realm:admin ist strikt lokal). Entferne die realm-admin-Rolle, um zu aktivieren." + }, "emailMode": { "ownAddress": "Eigene Adresse", "sharedHelp": "Benachrichtigungen gehen an diese Adresse.", @@ -906,6 +913,8 @@ "autoCreate": "Neue User beim ersten Login automatisch anlegen (JIT)", "allowLinking": "User dürfen diesen Provider im Profil verknüpfen", "trustForEmailLink": "Email-basierte Auto-Verknüpfung — bei gleicher Email an bestehenden lokalen User binden (GEFÄHRLICH: nur bei tenant-eigenen Providern)", + "trustForAuthorization": "Für Autorisierung vertrauen — Logins über diesen Provider dürfen session-weise Gruppenzugehörigkeit ableiten (nur „extern treibbare\" Gruppen; niemals realm:admin). Standard: aus.", + "authoritativeForProfile": "Profil-autoritativ — dieser Provider darf die Profilfelder (Vorname/Nachname/Email/Kürzel) bei jedem Login schreiben. Standard: aus (der anlegende Provider ist per Default autoritativ).", "allowedEmailDomains": "Erlaubte Email-Domänen (komma-getrennt, leer = kein Filter)", "storeRawClaims": "Roh-Claim-Snapshots pro Login speichern (für Debugging)", "rawRetentionDays": "Aufbewahrung der Rohclaims (Tage, leer = unbegrenzt)", diff --git a/src/frontend-vue/src/models/group.ts b/src/frontend-vue/src/models/group.ts index 32d9da26..3e80b73b 100644 --- a/src/frontend-vue/src/models/group.ts +++ b/src/frontend-vue/src/models/group.ts @@ -11,6 +11,12 @@ export interface GroupDto { MembershipMode: MembershipMode MembershipScript?: string MembershipLastError?: string | null + /** + * Federation v1: when true, an Auto group may receive externally-derived + * membership at login time (session-scoped, never written to MemberIds). + * A group whose roles confer realm:admin cannot be marked drivable. + */ + ExternallyDrivable?: boolean Email?: string EmailMode: EmailMode /** diff --git a/src/frontend-vue/src/models/loginProvider.ts b/src/frontend-vue/src/models/loginProvider.ts index 70e1a6a7..26c778cc 100644 --- a/src/frontend-vue/src/models/loginProvider.ts +++ b/src/frontend-vue/src/models/loginProvider.ts @@ -28,6 +28,10 @@ export interface LoginProviderDto { AutoCreateUsers: boolean AllowLinking: boolean TrustForEmailLink: boolean + /** Federation v1: login through this provider may confer ExternallyDrivable group membership for the session (never realm:admin). Default false. */ + TrustForAuthorization: boolean + /** Federation v1: this provider may write the profile fields (firstname/lastname/email/acronym). Default false (the JIT creator is authoritative by default). */ + AuthoritativeForProfile: boolean AllowedEmailDomains?: string[] | null IconName?: string | null ButtonColorHex?: string | null @@ -106,6 +110,8 @@ export interface CreateLoginProviderRequest { AutoCreateUsers?: boolean | null AllowLinking?: boolean | null TrustForEmailLink?: boolean | null + TrustForAuthorization?: boolean | null + AuthoritativeForProfile?: boolean | null AllowedEmailDomains?: string[] | null IconName?: string | null ButtonColorHex?: string | null @@ -125,6 +131,8 @@ export interface UpdateLoginProviderRequest { AutoCreateUsers?: boolean AllowLinking?: boolean TrustForEmailLink?: boolean + TrustForAuthorization?: boolean + AuthoritativeForProfile?: boolean AllowedEmailDomains?: string[] | null IconName?: string | null ButtonColorHex?: string | null diff --git a/src/frontend-vue/src/stores/group.store.ts b/src/frontend-vue/src/stores/group.store.ts index 0789f5b9..97e99eb4 100644 --- a/src/frontend-vue/src/stores/group.store.ts +++ b/src/frontend-vue/src/stores/group.store.ts @@ -29,6 +29,8 @@ interface GroupPayload { RoleIds: string[] MembershipMode: MembershipMode MembershipScript?: string + /** Federation v1: opt this Auto group into login-time externally-derived membership. */ + ExternallyDrivable?: boolean Email?: string EmailMode?: 'Shared' | 'ExpandToMembers' /** diff --git a/src/frontend-vue/src/views/admin/group/GroupDetails.vue b/src/frontend-vue/src/views/admin/group/GroupDetails.vue index 4bcc0a29..20e1a102 100644 --- a/src/frontend-vue/src/views/admin/group/GroupDetails.vue +++ b/src/frontend-vue/src/views/admin/group/GroupDetails.vue @@ -63,6 +63,9 @@ const form = ref({ MembershipMode: 'Manual' as MembershipMode, MembershipScript: '', MembershipLastError: null as string | null, + // Federation v1: only meaningful for Auto groups — opts the group into + // login-time externally-derived membership (session-scoped). + ExternallyDrivable: false, Email: '' as string | undefined, EmailMode: 'Shared' as EmailMode, // App slugs the group is active in. The synthetic "*" entry means @@ -96,6 +99,23 @@ const modalTitle = computed(() => { const isAutoMode = computed(() => form.value.MembershipMode === 'Auto') +// Federation v1: a group whose selected roles confer realm:admin can NEVER be +// externally drivable (realm:admin is hard local-only). Mirror the backend +// GroupMembershipGuards check so the toggle disables before the API rejects. +const hasRealmAdminRole = computed(() => + roleStore.roles.some(r => form.value.RoleIds.includes(r.Id) && r.IsRealmAdmin)) + +// ExternallyDrivable only has effect on Auto groups (the deriver evaluates only +// Auto + drivable). Disable the toggle otherwise, and when a realm-admin role is +// selected (the guarded case). +const externallyDrivableDisabled = computed(() => !isAutoMode.value || hasRealmAdminRole.value) + +// Keep the persisted flag honest: if the group leaves Auto mode or gains a +// realm-admin role, clear the toggle so a stale true can't linger. +watch(externallyDrivableDisabled, (disabled) => { + if (disabled) form.value.ExternallyDrivable = false +}) + const membershipModeOptions = computed(() => [ { value: 'Manual', label: t('admin.groupDetails.membership.manual', {}, 'Manual') }, { value: 'Auto', label: t('admin.groupDetails.membership.auto', {}, 'Automatic (script)') }, @@ -276,6 +296,7 @@ onMounted(async () => { MembershipMode: group.MembershipMode || 'Manual', MembershipScript: group.MembershipScript || '', MembershipLastError: group.MembershipLastError ?? null, + ExternallyDrivable: group.ExternallyDrivable ?? false, Email: group.Email || '', EmailMode: group.EmailMode || 'Shared', BoundTo: [...(group.BoundTo ?? [])], @@ -300,6 +321,9 @@ async function save() { RoleIds: form.value.RoleIds, MembershipMode: form.value.MembershipMode, MembershipScript: isAutoMode.value ? form.value.MembershipScript : undefined, + // Only an Auto group can be externally driven; never send true for Manual + // (the deriver ignores non-Auto groups anyway, but keep the payload honest). + ExternallyDrivable: isAutoMode.value && form.value.ExternallyDrivable && !hasRealmAdminRole.value, Email: form.value.Email?.trim() || undefined, EmailMode: form.value.EmailMode, BoundTo: [...form.value.BoundTo], @@ -394,6 +418,22 @@ async function save() { : t('admin.groupDetails.membership.manualHint', {}, 'Pick members directly in the Members tab.') }}

+ + + {{ t('admin.groupDetails.externallyDrivable.toggle', {}, 'Externally drivable (federation)') }} + +

+ + + +

+
Type.Is(p, 'person') && p.Email?.endsWith('@example.com')", }, + { + // Federation v1 (ExternallyDrivable groups only): session-scoped surface. + // p.ExternalGroups is the current provider's groups claim (always an array); + // p.Source is "local" or "provider:". Never written to durable members. + description: "Federation: upstream IdP group (only for 'Externally drivable' groups)", + code: "(p) => Type.Is(p, 'person') && p.IsActive && p.ExternalGroups.includes('entra-admins')", + }, + { + description: "Federation: scope a rule to one provider via p.Source", + code: "(p) => p.Source === 'provider:acme-entra' && p.ExternalGroups.includes('finance')", + }, ] export const membershipPreamble = diff --git a/src/frontend-vue/src/views/admin/login-providers/LoginProviderDetails.vue b/src/frontend-vue/src/views/admin/login-providers/LoginProviderDetails.vue index a6d3ea1c..3ee367f8 100644 --- a/src/frontend-vue/src/views/admin/login-providers/LoginProviderDetails.vue +++ b/src/frontend-vue/src/views/admin/login-providers/LoginProviderDetails.vue @@ -54,6 +54,8 @@ interface FormState { AutoCreateUsers: boolean AllowLinking: boolean TrustForEmailLink: boolean + TrustForAuthorization: boolean + AuthoritativeForProfile: boolean AllowedEmailDomains: string[] IconName: string ButtonColorHex: string @@ -75,6 +77,8 @@ function emptyForm(): FormState { AutoCreateUsers: false, AllowLinking: true, TrustForEmailLink: false, + TrustForAuthorization: false, + AuthoritativeForProfile: false, AllowedEmailDomains: [], IconName: '', ButtonColorHex: '', @@ -224,6 +228,8 @@ async function load() { AutoCreateUsers: existing.AutoCreateUsers, AllowLinking: existing.AllowLinking, TrustForEmailLink: existing.TrustForEmailLink, + TrustForAuthorization: existing.TrustForAuthorization, + AuthoritativeForProfile: existing.AuthoritativeForProfile, AllowedEmailDomains: existing.AllowedEmailDomains ? [...existing.AllowedEmailDomains] : [], IconName: existing.IconName ?? '', ButtonColorHex: existing.ButtonColorHex ?? '', @@ -327,6 +333,8 @@ async function createProvider() { AutoCreateUsers: form.value.AutoCreateUsers, AllowLinking: form.value.AllowLinking, TrustForEmailLink: form.value.TrustForEmailLink, + TrustForAuthorization: form.value.TrustForAuthorization, + AuthoritativeForProfile: form.value.AuthoritativeForProfile, AllowedEmailDomains: form.value.AllowedEmailDomains.length > 0 ? form.value.AllowedEmailDomains : null, IconName: form.value.IconName || null, ButtonColorHex: form.value.ButtonColorHex || null, @@ -368,6 +376,8 @@ async function updateProvider() { AutoCreateUsers: form.value.AutoCreateUsers, AllowLinking: form.value.AllowLinking, TrustForEmailLink: form.value.TrustForEmailLink, + TrustForAuthorization: form.value.TrustForAuthorization, + AuthoritativeForProfile: form.value.AuthoritativeForProfile, AllowedEmailDomains: form.value.AllowedEmailDomains.length > 0 ? form.value.AllowedEmailDomains : null, IconName: form.value.IconName || null, ButtonColorHex: form.value.ButtonColorHex || null, @@ -725,6 +735,13 @@ const showOidcConnectionFields = computed(() => !isSaml.value) +
+ + + +