diff --git a/docs/concepts/apps-and-resource-access.md b/docs/concepts/apps-and-resource-access.md index c88999a7..2929efdd 100644 --- a/docs/concepts/apps-and-resource-access.md +++ b/docs/concepts/apps-and-resource-access.md @@ -52,8 +52,15 @@ is the organisational clamp.** Every artefact below the realm sits on one of these axes. App-scoped artefacts (`PermissionRole.AppId`, `OAuthScope.AppId`, -`OAuthApi.AppId`) reach back up to the App; `Group.BoundTo` is the -activation switch ("is this group active in app X?"). +`OAuthApi.AppId`) reach back up to the App. `Group.BoundTo` assigns a +group to one or more Apps: its effective members belong to those Apps' +Principal scopes, and its roles are active there. + +This also supports pure assignment groups. A group with `BoundTo = +["acme"]` and no roles grants no permission, but its effective members +still belong to Acme's Principal scope. Modgud therefore needs no second +per-user or per-position App-assignment list. The `"*"` binding assigns +the group to every App in the realm. ## The App's second facet: a login experience (ADR-0011) diff --git a/docs/integrate/management-api.md b/docs/integrate/management-api.md index 98d7b427..00f53b85 100644 --- a/docs/integrate/management-api.md +++ b/docs/integrate/management-api.md @@ -41,12 +41,15 @@ Use this flow when no person is present: 1. Create a role containing only the required Modgud permissions. For the Position reads, grant `position:read`. Terminal provisioning needs both - `position:write` and `oauth-client:write`. + `position:write` and `oauth-client:write`. Reading an Application's complete + Principal scope needs `app-scope:read`. 2. Create or choose a group, attach the role, and add the Service Account as a member. 3. On that Service Account, issue a credential and allow the `modgud.management` scope. Modgud pins the resulting OAuth client to - `client_credentials` and to that Service Account. + `client_credentials` and to that Service Account. Assign the OAuth client to + each Application whose scope it may read; scope reads never treat an empty + `AppIds` list as permission to read every Application. 4. Store the one-time client secret in the consumer's secret store. Request a token with the standard OAuth wire format: @@ -137,6 +140,68 @@ or inline-creating a Service Account additionally needs `service-account:write`. This prevents a client administrator from minting a credential for a more privileged machine identity. +## Read an Application's Principal scope + +`GET /api/app/{appId}/scope` returns one consistent full read of the Principals +assigned to an Application. It requires `app-scope:read` and accepts the App id +as a Guid or ShortGuid. A bearer caller may only target Applications listed in +its OAuth client's `AppIds`; an empty assignment grants no scope access. A +cookie-authenticated administrator with the same permission may read any +Application in the realm. + +There is no separate scope configuration. Modgud derives the result from the +existing group graph: + +1. Every active group whose `BoundTo` contains the App slug or `*` is a scope + root. +2. Each root, its nested groups, and every transitive active member belong to + the scope. +3. All Principal kinds use the same rule: Person, Position, Service Account, + and Group. + +A group may deliberately have no roles. It then grants no permission while +still assigning its members to the selected Application scopes. The admin UI +marks such a group as **No permissions**. + +The response includes an opaque `scopeVersion`, the contributing root groups, +and the typed Principal records: + +```json +{ + "appId": "", + "appSlug": "alert-hub", + "scopeVersion": "v1-", + "rootGroups": [ + { + "id": "", + "name": "AlertHub principals", + "hasPermissions": false + } + ], + "principals": [ + { + "id": "", + "type": "person", + "displayName": "AP | Alice Person", + "isActive": true, + "isScopeRoot": false, + "accountName": "alice", + "firstname": "Alice", + "lastname": "Person", + "acronym": "AP", + "email": "alice@example.com" + } + ] +} +``` + +`scopeVersion` versions the **definition**, not every member. Adding or removing +an App binding, changing the nested-group structure, or changing an automatic +membership predicate changes it and tells a consumer to perform a new full +read. Ordinary direct membership/profile changes leave it stable; the resumable +change stream can therefore represent those as individual changes. Consumers +must treat the version as opaque and compare it for equality only. + ## Delegated-person setup Use this flow when the consumer should act with the permissions of a signed-in @@ -183,6 +248,7 @@ scheme does not turn every cookie-only admin route into a remote API. |---|---|---|---| | `GET` | `/api/position` | `position:read` | `PositionTerminals` enabled | | `GET` | `/api/position/{id}` | `position:read` | `PositionTerminals` enabled | +| `GET` | `/api/app/{id}/scope` | `app-scope:read` | Full `BoundTo`-derived Principal snapshot | | `POST` | `/api/admin/oauth/clients` | `oauth-client:write` | `position:write` for terminal provisioning; `service-account:write` for an SA link | Direct Position creation, mutation, deletion, grants, terminal enrollment, and diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationScopeApiTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationScopeApiTests.cs new file mode 100644 index 00000000..60bf69cf --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationScopeApiTests.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using BuildingBlocks.Helper; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.OAuth; +using Modgud.Application.Services; +using Modgud.Authorization.Apps; +using Modgud.Authorization.Events; +using Modgud.Authorization.Principals; +using Modgud.Domain.OAuth.Common; +using Modgud.Domain.OAuth.Management; + +namespace Modgud.Api.Tests.Authorization; + +[Collection(IntegrationTestCollection.Name)] +public class ApplicationScopeApiTests : IntegrationTestBase +{ + public ApplicationScopeApiTests(SharedPostgresFixture fixture) : base(fixture) { } + + private sealed record ScopeRoot(string Id, string Name, bool HasPermissions); + private sealed record ScopePrincipal(string Id, string Type, string DisplayName, bool IsScopeRoot); + private sealed record ScopeResponse( + string AppId, + string AppSlug, + string ScopeVersion, + List RootGroups, + List Principals); + + [Fact] + public async Task Full_read_uses_bound_groups_and_keeps_version_stable_for_membership_changes() + { + var ct = TestContext.Current.CancellationToken; + var appId = Guid.NewGuid(); + var positionId = Guid.NewGuid(); + var serviceAccount = new ServiceAccount + { + Id = Guid.NewGuid(), + AccountName = "scope-sync", + Purpose = "Scope API test", + }; + var nestedId = Guid.NewGuid(); + var rootId = Guid.NewGuid(); + + await using (var session = GetTenantedDocumentSession()) + { + session.Events.StartStream(appId, new AppCreatedEvent( + appId, "scope-test", "Scope test", null, [], IsSystem: false)); + session.Events.StartStream(positionId, new PositionPrincipalCreatedEvent( + positionId, "gate", "Gate", true, PositionTerminalPolicy.Disabled)); + session.Store(serviceAccount); + session.Events.StartStream(nestedId, new GroupCreatedEvent( + nestedId, "Nested", null, + [positionId, serviceAccount.Id], [], BoundTo: [])); + session.Events.StartStream(rootId, new GroupCreatedEvent( + rootId, "Scope only", null, + [DefaultUser!.Id, nestedId], [], BoundTo: ["scope-test"])); + await session.SaveChangesAsync(ct); + } + + var path = $"/api/app/{new ShortGuid(appId)}/scope"; + var firstResponse = await Client.GetAsync(path, ct); + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + var first = await firstResponse.Content.ReadFromJsonAsync(JsonOptions, ct); + + Assert.NotNull(first); + Assert.Equal("scope-test", first.AppSlug); + var ownRoot = Assert.Single( + first.RootGroups, + root => root.Id == new ShortGuid(rootId).ToString()); + Assert.False(ownRoot.HasPermissions); + + var expectedPrincipals = new Dictionary + { + [rootId] = "group", + [nestedId] = "group", + [DefaultUser!.Id] = "person", + [positionId] = "position", + [serviceAccount.Id] = "service-account", + }; + foreach (var (principalId, principalType) in expectedPrincipals) + { + var principal = Assert.Single( + first.Principals, + candidate => candidate.Id == new ShortGuid(principalId).ToString()); + Assert.Equal(principalType, principal.Type); + } + + Assert.True(Assert.Single( + first.Principals, + principal => principal.Id == new ShortGuid(rootId).ToString()).IsScopeRoot); + + var secondServiceAccount = new ServiceAccount + { + Id = Guid.NewGuid(), + AccountName = "scope-sync-2", + }; + await using (var session = GetTenantedDocumentSession()) + { + session.Store(secondServiceAccount); + session.Events.Append(rootId, new GroupUpdatedEvent( + rootId, "Scope only", null, + [DefaultUser!.Id, nestedId, secondServiceAccount.Id], [], + BoundTo: ["scope-test"])); + await session.SaveChangesAsync(ct); + } + + var second = await Client.GetFromJsonAsync(path, JsonOptions, ct); + Assert.NotNull(second); + Assert.Equal(first.ScopeVersion, second.ScopeVersion); + Assert.Contains(second.Principals, p => p.Id == new ShortGuid(secondServiceAccount.Id).ToString()); + + var secondRootId = Guid.NewGuid(); + await using (var session = GetTenantedDocumentSession()) + { + session.Events.StartStream(secondRootId, new GroupCreatedEvent( + secondRootId, "Second root", null, [], [], BoundTo: ["scope-test"])); + await session.SaveChangesAsync(ct); + } + + var third = await Client.GetFromJsonAsync(path, JsonOptions, ct); + Assert.NotNull(third); + Assert.NotEqual(second.ScopeVersion, third.ScopeVersion); + } + + [Fact] + public async Task Bearer_client_can_read_only_its_assigned_application_scope() + { + var ct = TestContext.Current.CancellationToken; + var ownAppId = Guid.NewGuid(); + var foreignAppId = Guid.NewGuid(); + var serviceAccount = new ServiceAccount + { + Id = Guid.NewGuid(), + AccountName = $"scope-reader-{Guid.NewGuid():N}", + }; + + await using (var session = GetTenantedDocumentSession()) + { + session.Events.StartStream(ownAppId, new AppCreatedEvent( + ownAppId, $"scope-own-{Guid.NewGuid():N}", "Own App", null, [], IsSystem: false)); + session.Events.StartStream(foreignAppId, new AppCreatedEvent( + foreignAppId, $"scope-foreign-{Guid.NewGuid():N}", "Foreign App", null, [], IsSystem: false)); + session.Store(serviceAccount); + await session.SaveChangesAsync(ct); + } + + var role = await Factory.CreateTestRoleAsync( + $"AppScopeReader_{Guid.NewGuid():N}", [("app-scope", "read")]); + await Factory.CreateTestGroupAsync( + $"AppScopeReaders_{Guid.NewGuid():N}", [serviceAccount.Id], [role.Id]); + + var clientId = $"app-scope-reader-{Guid.NewGuid():N}"; + await CreateManagementClientAsync(clientId, serviceAccount.Id, ownAppId); + var token = await IssueManagementTokenAsync(clientId); + + using var own = await SendScopeGetAsync(token, ownAppId); + Assert.Equal(HttpStatusCode.OK, own.StatusCode); + + using var foreign = await SendScopeGetAsync(token, foreignAppId); + var body = await foreign.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Forbidden, foreign.StatusCode); + Assert.Contains("Management.ClientAppMismatch", body); + } + + private async Task CreateManagementClientAsync( + string clientId, + Guid serviceAccountId, + Guid appId) + { + using var scope = Factory.Services.CreateScope(); + var oauth = scope.ServiceProvider.GetRequiredService(); + var result = await oauth.CreateClientAsync(new CreateOAuthClientDto + { + ClientId = clientId, + ClientSecret = $"{clientId}-secret", + ClientType = OAuthClientTypes.Confidential, + ConsentType = OAuthConsentTypes.Implicit, + DisplayName = clientId, + RedirectUris = [], + PostLogoutRedirectUris = [], + Scopes = [ModgudManagementApi.Scope], + AllowedGrantTypes = ["client_credentials"], + RequireConsent = false, + AccessTokenType = AccessTokenType.Jwt, + AppIds = [new ShortGuid(appId).ToString()], + LinkedServiceAccountId = new ShortGuid(serviceAccountId).ToString(), + }, TestContext.Current.CancellationToken); + if (result.IsError) + { + throw new InvalidOperationException( + $"CreateClientAsync failed: {string.Join(", ", result.Errors.Select(error => $"{error.Code}: {error.Description}"))}"); + } + } + + private async Task IssueManagementTokenAsync(string clientId) + { + var form = new List> + { + new("grant_type", "client_credentials"), + new("client_id", clientId), + new("client_secret", $"{clientId}-secret"), + new("scope", ModgudManagementApi.Scope), + new("resource", ModgudManagementApi.Audience), + }; + using var tokenClient = Factory.CreateClient(); + using var response = await tokenClient.PostAsync( + "/connect/token", + new FormUrlEncodedContent(form), + TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(response.IsSuccessStatusCode, + $"client_credentials failed ({(int)response.StatusCode}): {body}"); + using var document = JsonDocument.Parse(body); + return document.RootElement.GetProperty("access_token").GetString()!; + } + + private async Task SendScopeGetAsync(string token, Guid appId) + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"/api/app/{new ShortGuid(appId)}/scope"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var client = Factory.CreateClient(); + return await client.SendAsync(request, TestContext.Current.CancellationToken); + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs index d04d4a6f..7fb5ebfc 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs @@ -4,8 +4,10 @@ using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; using Modgud.Authentication.Applications; +using Modgud.Api.Features.Management; using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; +using Modgud.Authorization.Principals; using Marten; namespace Modgud.Api.Features.Admin.Apps; @@ -166,6 +168,40 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s .WithName("V2_App_Delete") .RequiresPermission("app:write"); + // Deliberately mapped outside appGroup: that group is cookie-authenticated, + // while this read is also part of the explicitly exposed OAuth Management + // API. The management filter selects cookie or bearer and evaluates the + // same live app-scope:read permission for both. Bearer callers are also + // constrained to the target Apps assigned to their OAuth client. + application.MapGet($"{path}/app/{{id}}/scope", async ( + ShortGuid id, + IApplicationScopeResolver resolver, + CancellationToken ct) => + { + var snapshot = await resolver.ResolveAsync(id.Guid, ct); + if (snapshot is null) return Results.NotFound(); + + var rootIds = snapshot.RootGroups.Select(g => g.Id).ToHashSet(); + return Results.Ok(new + { + AppId = new ShortGuid(snapshot.AppId).ToString(), + snapshot.AppSlug, + snapshot.ScopeVersion, + RootGroups = snapshot.RootGroups.Select(g => new + { + Id = new ShortGuid(g.Id).ToString(), + g.Name, + HasPermissions = g.RoleIds.Count > 0, + }), + Principals = snapshot.Principals.Select(p => MapScopePrincipal(p, rootIds)), + }); + }) + .WithTags("Apps") + .WithName("V2_App_GetScope") + .RequiresManagementPermission( + "app-scope:read", + clientAppRouteParameter: "id"); + return application; } @@ -190,6 +226,31 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s Settings = settings, }; + private static object MapScopePrincipal(Principal principal, IReadOnlySet rootGroupIds) + { + var person = principal as Person; + var group = principal as Group; + var serviceAccount = principal as ServiceAccount; + var position = principal as PositionPrincipal; + + return new + { + Id = new ShortGuid(principal.Id).ToString(), + principal.Type, + principal.DisplayName, + principal.IsActive, + IsScopeRoot = rootGroupIds.Contains(principal.Id), + AccountName = person?.AccountName ?? serviceAccount?.AccountName ?? position?.AccountName, + person?.Firstname, + person?.Lastname, + person?.Acronym, + person?.Email, + Name = group?.Name, + Description = group?.Description, + Purpose = serviceAccount?.Purpose ?? position?.Purpose, + }; + } + // Renders an AppAdminService ErrorOr error with the error code in the body. The shared // ErrorOrExtensions.ToResult collapses to { error: description } (no code) — the app // admin SPA and the catalog security tests assert on the code, so keep {Error,Message}. diff --git a/src/dotnet/Modgud.Api/Features/Groups/AutoMembershipSyncHandlers.cs b/src/dotnet/Modgud.Api/Features/Groups/AutoMembershipSyncHandlers.cs index c51ff1e3..8614451d 100644 --- a/src/dotnet/Modgud.Api/Features/Groups/AutoMembershipSyncHandlers.cs +++ b/src/dotnet/Modgud.Api/Features/Groups/AutoMembershipSyncHandlers.cs @@ -131,6 +131,46 @@ protected override Task SyncAsync(UserDeletedEvent @event, IDocumentSession sess => recalculator.RecalculateForPrincipalAsync(@event.Id, session, changedPaths: null); } +/// +/// Position principals participate in the same group graph as Persons and +/// Service Accounts. Keeping their auto-membership materialized is therefore +/// required for BoundTo-derived Application scopes as well as authorization. +/// Position events are full-state events, so the safe dependency signal is +/// null (re-evaluate every auto group). +/// +public class AutoMembershipOnPositionCreatedHandler( + IAutoMembershipRecalculator recalculator, + ILogger logger) + : ReferenceSyncHandler(logger) +{ + protected override bool ShouldSync(PositionPrincipalCreatedEvent @event) => true; + + protected override Task SyncAsync(PositionPrincipalCreatedEvent @event, IDocumentSession session) + => recalculator.RecalculateForPrincipalAsync(@event.Id, session, changedPaths: null); +} + +public class AutoMembershipOnPositionUpdatedHandler( + IAutoMembershipRecalculator recalculator, + ILogger logger) + : ReferenceSyncHandler(logger) +{ + protected override bool ShouldSync(PositionPrincipalUpdatedEvent @event) => true; + + protected override Task SyncAsync(PositionPrincipalUpdatedEvent @event, IDocumentSession session) + => recalculator.RecalculateForPrincipalAsync(@event.Id, session, changedPaths: null); +} + +public class AutoMembershipOnPositionDeletedHandler( + IAutoMembershipRecalculator recalculator, + ILogger logger) + : ReferenceSyncHandler(logger) +{ + protected override bool ShouldSync(PositionPrincipalDeletedEvent @event) => true; + + protected override Task SyncAsync(PositionPrincipalDeletedEvent @event, IDocumentSession session) + => recalculator.RecalculateForPrincipalAsync(@event.Id, session, changedPaths: null); +} + /// /// Reacts to UserExternalIdentityLinkedEvent / ...UnlinkedEvent — linking or /// unlinking an external identity mutates Person.ExternalIdentities, which diff --git a/src/dotnet/Modgud.Api/Features/Management/ManagementPermissionEndpointFilter.cs b/src/dotnet/Modgud.Api/Features/Management/ManagementPermissionEndpointFilter.cs index 3beb1428..5a3c5f6b 100644 --- a/src/dotnet/Modgud.Api/Features/Management/ManagementPermissionEndpointFilter.cs +++ b/src/dotnet/Modgud.Api/Features/Management/ManagementPermissionEndpointFilter.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Json; +using BuildingBlocks.Helper; using Marten; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -22,7 +23,9 @@ namespace Modgud.Api.Features.Management; /// the management API; the system-App permission remains the single source of /// fine-grained authorization for both Persons and ServiceAccounts. /// -public sealed class ManagementPermissionEndpointFilter(string permission) : IEndpointFilter +public sealed class ManagementPermissionEndpointFilter( + string permission, + string? clientAppRouteParameter = null) : IEndpointFilter { public async ValueTask InvokeAsync( EndpointFilterInvocationContext context, @@ -64,6 +67,13 @@ await ValidateServiceAccountClientAsync(http, caller, serviceAccount) is { } cli await ValidateDelegatedClientAsync(http, caller) is { } delegatedClientError) return delegatedClientError; + if (bearerRequest && clientAppRouteParameter is not null && + await ValidateClientAppBoundaryAsync(http, caller, clientAppRouteParameter) + is { } appBoundaryError) + { + return appBoundaryError; + } + var permissionService = http.RequestServices.GetRequiredService(); if (!await permissionService.HasPermissionAsync( principalId, AppSlugs.Modgud, permission, http.RequestAborted)) @@ -168,6 +178,37 @@ await ValidateDelegatedClientAsync(http, caller) is { } delegatedClientError) return null; } + private static async Task ValidateClientAppBoundaryAsync( + HttpContext http, + ClaimsPrincipal caller, + string routeParameter) + { + var raw = http.Request.RouteValues.TryGetValue(routeParameter, out var value) + ? value?.ToString() + : null; + if (raw is null || !ShortGuid.TryParse(raw, out Guid targetAppId)) + { + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Invalid Application id", + detail: $"The route value '{{{routeParameter}}}' is not a valid Application id.", + extensions: new Dictionary + { + ["code"] = "Management.InvalidTargetApp", + }); + } + + var client = await LoadClientAsync(http, caller); + if (client is null || !client.AppIds.Contains(targetAppId)) + { + return Forbidden( + "Management.ClientAppMismatch", + $"The OAuth client is not assigned to Application '{new ShortGuid(targetAppId)}'."); + } + + return null; + } + private static async Task LoadClientAsync( HttpContext http, ClaimsPrincipal caller) @@ -230,13 +271,16 @@ public static class ManagementPermissionEndpointExtensions /// public static RouteHandlerBuilder RequiresManagementPermission( this RouteHandlerBuilder builder, - string permission) + string permission, + string? clientAppRouteParameter = null) { builder.RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = AuthenticationSchemes, }); - builder.AddEndpointFilter(new ManagementPermissionEndpointFilter(permission)); + builder.AddEndpointFilter(new ManagementPermissionEndpointFilter( + permission, + clientAppRouteParameter)); return builder; } } diff --git a/src/dotnet/Modgud.Authorization/Apps/ApplicationScopeResolver.cs b/src/dotnet/Modgud.Authorization/Apps/ApplicationScopeResolver.cs new file mode 100644 index 00000000..4493e576 --- /dev/null +++ b/src/dotnet/Modgud.Authorization/Apps/ApplicationScopeResolver.cs @@ -0,0 +1,136 @@ +using System.Security.Cryptography; +using System.Text; +using Marten; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Services; + +namespace Modgud.Authorization.Apps; + +/// +/// Resolves the business-principal slice of one Application from the existing +/// group graph. A live, active group is a root when its BoundTo contains +/// the Application slug or *. The scope is the transitive closure of +/// those roots and their members, across every shipped Principal type. +/// +public interface IApplicationScopeResolver +{ + Task ResolveAsync(Guid appId, CancellationToken ct = default); +} + +public sealed record ApplicationScopeSnapshot( + Guid AppId, + string AppSlug, + string ScopeVersion, + IReadOnlyList RootGroups, + IReadOnlyList Principals); + +public sealed class ApplicationScopeResolver(IQuerySession session) : IApplicationScopeResolver +{ + public async Task ResolveAsync( + Guid appId, + CancellationToken ct = default) + { + var app = await session.LoadAsync(appId, ct); + if (app is null || app.IsDeleted) return null; + + // One directory query gives the version and all members one consistent + // database snapshot. A multi-page/multi-query walk could otherwise pair + // an old root set with new membership (or vice versa). + var principals = await session.Query() + .Where(p => !p.IsDeleted && p.IsActive) + .ToListAsync(ct); + + return BuildSnapshot(app, principals); + } + + internal static ApplicationScopeSnapshot BuildSnapshot( + App app, + IReadOnlyCollection directory) + { + var activeDirectory = directory + .Where(p => !p.IsDeleted && p.IsActive) + .ToList(); + var byId = activeDirectory.ToDictionary(p => p.Id); + var roots = activeDirectory + .OfType() + .Where(g => g.BoundTo.Contains(PermissionService.AllAppsWildcard, StringComparer.Ordinal) + || g.BoundTo.Contains(app.Slug, StringComparer.Ordinal)) + .OrderBy(g => g.Id) + .ToList(); + + var included = new HashSet(); + var pendingGroups = new Queue(); + foreach (var root in roots) + { + if (included.Add(root.Id)) pendingGroups.Enqueue(root.Id); + } + + while (pendingGroups.TryDequeue(out var groupId)) + { + if (!byId.TryGetValue(groupId, out var candidate) || candidate is not Group group) + continue; + + foreach (var memberId in group.MemberIds) + { + if (!byId.TryGetValue(memberId, out var member)) continue; + if (!included.Add(memberId)) continue; + if (member is Group) pendingGroups.Enqueue(memberId); + } + } + + var scopedPrincipals = included + .Select(id => byId[id]) + .OrderBy(p => p.Type, StringComparer.Ordinal) + .ThenBy(p => p.Id) + .ToList(); + var scopedGroups = scopedPrincipals.OfType().ToList(); + + return new ApplicationScopeSnapshot( + app.Id, + app.Slug, + BuildVersion(roots, scopedGroups), + roots, + scopedPrincipals); + } + + /// + /// Opaque definition version derived from the bound root set, nested-group + /// structure, and automatic-membership predicates. Ordinary direct member + /// changes deliberately leave it stable and will be represented as individual + /// events by the resumable change stream. + /// + internal static string BuildVersion( + IEnumerable rootGroups, + IEnumerable scopedGroups) + { + var roots = rootGroups.Select(g => g.Id).ToHashSet(); + var groups = scopedGroups + .GroupBy(g => g.Id) + .Select(g => g.First()) + .OrderBy(g => g.Id) + .ToList(); + var groupIds = groups.Select(g => g.Id).ToHashSet(); + + var lines = new List(groups.Count * 2); + lines.AddRange(roots.OrderBy(id => id).Select(id => $"root:{id:N}")); + foreach (var group in groups) + { + var script = group.MembershipMode == MembershipMode.Auto + ? group.MembershipScript ?? string.Empty + : string.Empty; + var scriptDigest = Convert.ToHexStringLower( + SHA256.HashData(Encoding.UTF8.GetBytes(script))); + var nestedGroupIds = group.MemberIds + .Where(groupIds.Contains) + .Distinct() + .OrderBy(id => id) + .Select(id => id.ToString("N")); + lines.Add($"group:{group.Id:N}|mode:{group.MembershipMode}|script:{scriptDigest}"); + lines.Add($"children:{group.Id:N}|{string.Join(',', nestedGroupIds)}"); + } + + var canonical = string.Join('\n', lines); + var digest = SHA256.HashData(Encoding.UTF8.GetBytes($"modgud-app-scope-v1\n{canonical}")); + return $"v1-{Convert.ToHexStringLower(digest)}"; + } +} diff --git a/src/dotnet/Modgud.Authorization/Principals/Group.cs b/src/dotnet/Modgud.Authorization/Principals/Group.cs index 7405624d..6ccfc939 100644 --- a/src/dotnet/Modgud.Authorization/Principals/Group.cs +++ b/src/dotnet/Modgud.Authorization/Principals/Group.cs @@ -35,10 +35,10 @@ public class Group : Principal, IPrincipalWithMembers, IPrincipalEmailAddressabl public List RoleIds { get; set; } = []; /// - /// App slugs in which this group is active. When a permission - /// check resolves (User, App), only groups with the requested - /// app in contribute to the user's effective - /// permissions in that app. + /// App slugs to which this group is assigned. The group and its effective + /// members form part of each assigned Application's Principal scope, even + /// when is empty. When a permission check resolves + /// (User, App), only groups assigned to that app contribute roles. /// /// An empty list means the group is dormant for permission /// purposes (organisation-only — e.g. a distribution list). Removing diff --git a/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs b/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs index 7a5a883f..8b7221ff 100644 --- a/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs +++ b/src/dotnet/Modgud.Authorization/Setup/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Marten; using Microsoft.Extensions.DependencyInjection; using Modgud.Authorization.Membership; +using Modgud.Authorization.Apps; using Modgud.Authorization.Resources; using Modgud.Authorization.Services; @@ -26,6 +27,7 @@ public static IServiceCollection AddModgudAuthorization( services.AddSingleton(options.ResourceRegistry); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs b/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs index d7581766..e4380f06 100644 --- a/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs +++ b/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs @@ -33,6 +33,7 @@ private static readonly (string Resource, string[] Actions)[] ModgudCatalog = // into this realm). The system app modgud is seeded // automatically and cannot be deleted. ("app", ["admin", "read", "write"]), + ("app-scope", ["read"]), // Identity / directory ("user", ["read", "write"]), diff --git a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs index d1d4d17d..48c1d4fa 100644 --- a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs +++ b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs @@ -221,6 +221,10 @@ public static IServiceCollection AddInfrastructure( // into this realm). The system app modgud is seeded // automatically and cannot be deleted. opt.RegisterResource(app, "app", "admin", "read", "write"); + // Read the complete, BoundTo-derived Principal slice of one App. + // Kept separate from app:read because the snapshot contains + // directory/profile data, not merely Application configuration. + opt.RegisterResource(app, "app-scope", "read"); // Identity / directory opt.RegisterResource(app, "user", "read", "write"); diff --git a/src/dotnet/Modgud.Tests.Unit/Api/Features/Groups/AutoMembershipSyncHandlersTests.cs b/src/dotnet/Modgud.Tests.Unit/Api/Features/Groups/AutoMembershipSyncHandlersTests.cs index 786c5902..9650e612 100644 --- a/src/dotnet/Modgud.Tests.Unit/Api/Features/Groups/AutoMembershipSyncHandlersTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Api/Features/Groups/AutoMembershipSyncHandlersTests.cs @@ -189,6 +189,24 @@ public TestableDeleted() : base(new ThrowingRecalculator(), NullLogger base.ShouldSync(@event); } + private sealed class TestablePositionCreated : AutoMembershipOnPositionCreatedHandler + { + public TestablePositionCreated() : base(new ThrowingRecalculator(), NullLogger.Instance) { } + public new bool ShouldSync(PositionPrincipalCreatedEvent @event) => base.ShouldSync(@event); + } + + private sealed class TestablePositionUpdated : AutoMembershipOnPositionUpdatedHandler + { + public TestablePositionUpdated() : base(new ThrowingRecalculator(), NullLogger.Instance) { } + public new bool ShouldSync(PositionPrincipalUpdatedEvent @event) => base.ShouldSync(@event); + } + + private sealed class TestablePositionDeleted : AutoMembershipOnPositionDeletedHandler + { + public TestablePositionDeleted() : base(new ThrowingRecalculator(), NullLogger.Instance) { } + public new bool ShouldSync(PositionPrincipalDeletedEvent @event) => base.ShouldSync(@event); + } + private sealed class TestableGroupUpdated : AutoMembershipOnGroupUpdatedHandler { public TestableGroupUpdated() : base(new ThrowingRecalculator(), NullLogger.Instance) { } @@ -241,6 +259,21 @@ public void UserDeactivated_always_syncs() => public void UserDeleted_always_syncs() => Assert.True(new TestableDeleted().ShouldSync(new UserDeletedEvent(Guid.NewGuid()))); + [Fact] + public void PositionCreated_always_syncs() => + Assert.True(new TestablePositionCreated().ShouldSync(new PositionPrincipalCreatedEvent( + Guid.NewGuid(), "gate", null, true, PositionTerminalPolicy.Disabled))); + + [Fact] + public void PositionUpdated_always_syncs() => + Assert.True(new TestablePositionUpdated().ShouldSync(new PositionPrincipalUpdatedEvent( + Guid.NewGuid(), "gate", "updated", true, PositionTerminalPolicy.Disabled))); + + [Fact] + public void PositionDeleted_always_syncs() => + Assert.True(new TestablePositionDeleted().ShouldSync( + new PositionPrincipalDeletedEvent(Guid.NewGuid()))); + [Fact] public void GroupUpdated_always_syncs() { diff --git a/src/dotnet/Modgud.Tests.Unit/Authorization/Apps/ApplicationScopeResolverTests.cs b/src/dotnet/Modgud.Tests.Unit/Authorization/Apps/ApplicationScopeResolverTests.cs new file mode 100644 index 00000000..f454179f --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Authorization/Apps/ApplicationScopeResolverTests.cs @@ -0,0 +1,148 @@ +using Modgud.Authorization.Apps; +using Modgud.Authorization.Principals; + +namespace Modgud.Tests.Unit.Authorization.Apps; + +public class ApplicationScopeResolverTests +{ + private static readonly App AlertHub = new() + { + Id = Guid.NewGuid(), + Slug = "alert-hub", + DisplayName = "AlertHub", + }; + + [Fact] + public void Bound_root_includes_every_principal_type_and_nested_members() + { + var person = new Person { Id = Guid.NewGuid(), AccountName = "alice" }; + var position = new PositionPrincipal { Id = Guid.NewGuid(), AccountName = "gate" }; + var serviceAccount = new ServiceAccount { Id = Guid.NewGuid(), AccountName = "sync" }; + var nested = new Group + { + Id = Guid.NewGuid(), + Name = "Nested", + MemberIds = [position.Id, serviceAccount.Id], + }; + var root = new Group + { + Id = Guid.NewGuid(), + Name = "AlertHub principals", + BoundTo = [AlertHub.Slug], + MemberIds = [person.Id, nested.Id], + RoleIds = [], + }; + var unrelated = new Person { Id = Guid.NewGuid(), AccountName = "bob" }; + + var result = ApplicationScopeResolver.BuildSnapshot( + AlertHub, + [person, position, serviceAccount, nested, root, unrelated]); + + Assert.Equal([root.Id], result.RootGroups.Select(g => g.Id)); + Assert.Equal( + new HashSet { root.Id, nested.Id, person.Id, position.Id, serviceAccount.Id }, + result.Principals.Select(p => p.Id).ToHashSet()); + Assert.DoesNotContain(result.Principals, p => p.Id == unrelated.Id); + } + + [Fact] + public void Wildcard_is_a_root_and_inactive_or_deleted_members_are_excluded() + { + var active = new Person { Id = Guid.NewGuid(), AccountName = "active" }; + var inactive = new Person { Id = Guid.NewGuid(), AccountName = "inactive", IsActive = false }; + var deleted = new ServiceAccount { Id = Guid.NewGuid(), AccountName = "deleted", IsDeleted = true }; + var wildcard = new Group + { + Id = Guid.NewGuid(), + Name = "Every app", + BoundTo = ["*"], + MemberIds = [active.Id, inactive.Id, deleted.Id], + }; + + var result = ApplicationScopeResolver.BuildSnapshot( + AlertHub, + [wildcard, active, inactive, deleted]); + + Assert.Contains(result.Principals, p => p.Id == wildcard.Id); + Assert.Contains(result.Principals, p => p.Id == active.Id); + Assert.DoesNotContain(result.Principals, p => p.Id == inactive.Id); + Assert.DoesNotContain(result.Principals, p => p.Id == deleted.Id); + } + + [Fact] + public void Group_without_roles_still_defines_the_scope() + { + var root = new Group + { + Id = Guid.NewGuid(), + Name = "Visibility only", + BoundTo = [AlertHub.Slug], + RoleIds = [], + }; + + var result = ApplicationScopeResolver.BuildSnapshot(AlertHub, [root]); + + Assert.Single(result.RootGroups); + Assert.Single(result.Principals); + } + + [Fact] + public void Version_changes_with_roots_but_not_with_membership() + { + var memberA = new Person { Id = Guid.NewGuid(), AccountName = "a" }; + var memberB = new Person { Id = Guid.NewGuid(), AccountName = "b" }; + var rootA = new Group + { + Id = Guid.NewGuid(), + Name = "A", + BoundTo = [AlertHub.Slug], + MemberIds = [memberA.Id], + }; + + var before = ApplicationScopeResolver.BuildSnapshot(AlertHub, [rootA, memberA]); + rootA.MemberIds = [memberA.Id, memberB.Id]; + var membershipChanged = ApplicationScopeResolver.BuildSnapshot(AlertHub, [rootA, memberA, memberB]); + + var rootB = new Group + { + Id = Guid.NewGuid(), + Name = "B", + BoundTo = [AlertHub.Slug], + }; + var definitionChanged = ApplicationScopeResolver.BuildSnapshot( + AlertHub, + [rootA, rootB, memberA, memberB]); + + Assert.Equal(before.ScopeVersion, membershipChanged.ScopeVersion); + Assert.NotEqual(before.ScopeVersion, definitionChanged.ScopeVersion); + } + + [Fact] + public void Version_is_independent_of_root_order() + { + var first = new Group { Id = Guid.NewGuid(), BoundTo = [AlertHub.Slug] }; + var second = new Group { Id = Guid.NewGuid(), BoundTo = [AlertHub.Slug] }; + + Assert.Equal( + ApplicationScopeResolver.BuildSnapshot(AlertHub, [first, second]).ScopeVersion, + ApplicationScopeResolver.BuildSnapshot(AlertHub, [second, first]).ScopeVersion); + } + + [Fact] + public void Version_changes_when_the_nested_group_definition_changes() + { + var nested = new Group { Id = Guid.NewGuid(), Name = "Nested" }; + var root = new Group + { + Id = Guid.NewGuid(), + Name = "Root", + BoundTo = [AlertHub.Slug], + }; + var before = ApplicationScopeResolver.BuildSnapshot(AlertHub, [root, nested]); + + root.MemberIds = [nested.Id]; + var after = ApplicationScopeResolver.BuildSnapshot(AlertHub, [root, nested]); + + Assert.NotEqual(before.ScopeVersion, after.ScopeVersion); + } +} diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index e15889e7..bae25823 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -797,6 +797,11 @@ "description": "Beschreibung", "members": "Mitglieder", "roles": "Rollen", + "permissions": "Berechtigungen", + "permissionsTag": { + "true": "Berechtigungen zugewiesen", + "false": "Keine Berechtigungen" + }, "membershipMode": "Typ", "membership": { "Manual": "Manuell", @@ -840,6 +845,7 @@ "type": "Typ", "selectMembers": "Mitglieder auswählen...", "roles": "Rollen", + "noPermissions": "Diese Gruppe vergibt keine Berechtigungen.", "selectRoles": "Rollen auswählen...", "examples": "Beispiele", "examplesIntro": "Leer = keine Einschränkung. Beispiele für Einschränkungen:", @@ -881,8 +887,8 @@ "boundTo": "In welchen Anwendungen aktiv", "boundTo.dormantHint": "Keine Anwendung ausgewählt — die Gruppe ist für Berechtigungen ruhend (z. B. reine Organisations-Gruppe / Verteiler). Sie erhält weiterhin E-Mails und erscheint in Mitglieder-Ansichten, aber ihre Rollen vergeben nichts.", "boundTo.placeholder": "Anwendungen wählen…", - "boundTo.scopedHint": "Trägt nur zur Berechtigungsauflösung bei, wenn die anfragende Anwendung hier ausgewählt ist. An diese Gruppe gebundene Rollen greifen nur in diesen Anwendungen.", - "boundTo.wildcardHint": "★ „Alle Anwendungen“ ausgewählt — diese Gruppe ist in jeder Anwendung des Realms aktiv. Typisch für die realm-admin-Gruppe.", + "boundTo.scopedHint": "Die Gruppe und ihre effektiven Mitglieder gehören zum Scope der ausgewählten Anwendungen. Zugewiesene Rollen greifen nur dort.", + "boundTo.wildcardHint": "★ „Alle Anwendungen“ ausgewählt — die Gruppe und ihre effektiven Mitglieder gehören zum Scope jeder Anwendung im Realm. Typisch für die realm-admin-Gruppe.", "boundTo.wildcardOption": "★ Alle Anwendungen (*) — realm-weit", "email": "E-Mail-Adresse", "validation": { diff --git a/src/frontend-vue/src/models/group.ts b/src/frontend-vue/src/models/group.ts index 3e80b73b..5e68f01d 100644 --- a/src/frontend-vue/src/models/group.ts +++ b/src/frontend-vue/src/models/group.ts @@ -20,10 +20,10 @@ export interface GroupDto { Email?: string EmailMode: EmailMode /** - * App slugs in which this group is *active*. When permission resolution - * runs against a given app, only groups whose BoundTo contains that app - * (or the wildcard "*") contribute. Empty = dormant (organisation-only - * group, e.g. a distribution list). + * App slugs to which this group is assigned. Its effective members belong + * to those Application scopes even when RoleIds is empty. During permission + * resolution, its roles contribute only for those apps. "*" means every + * Application; empty means organisation-only/dormant. */ BoundTo: string[] } diff --git a/src/frontend-vue/src/views/admin/group/GroupDetails.vue b/src/frontend-vue/src/views/admin/group/GroupDetails.vue index 4e950f38..ad901680 100644 --- a/src/frontend-vue/src/views/admin/group/GroupDetails.vue +++ b/src/frontend-vue/src/views/admin/group/GroupDetails.vue @@ -626,7 +626,7 @@ async function save() { ? t('admin.groupDetails.boundTo.wildcardHint', {}, '★ "All applications" selected — this group is active in every application in the realm. Typical for the realm-admin group.') : isDormantBoundTo ? t('admin.groupDetails.boundTo.dormantHint', {}, 'No applications selected — the group is dormant for permissions. It can still receive mail and have members, but its roles grant nothing.') - : t('admin.groupDetails.boundTo.scopedHint', {}, 'The assigned roles contribute only when the requesting application is selected here.')"> + : t('admin.groupDetails.boundTo.scopedHint', {}, 'The group and its effective members belong to the selected application scopes. Assigned roles contribute only there.')"> + + {{ t('admin.groupDetails.noPermissions', {}, 'This group grants no permissions.') }} + { if (!id) return null return appsStore.apps.find((a) => a.Id === id)?.Slug ?? null }) -const groups = computed(() => +type GroupListRow = GroupDto & { HasPermissions: boolean } + +const groups = computed(() => groupStore.groups.filter((g) => - appCtx.matchesBoundToSlugs(g.BoundTo, selectedAppSlug.value))) + appCtx.matchesBoundToSlugs(g.BoundTo, selectedAppSlug.value)) + .map((g) => ({ ...g, HasPermissions: g.RoleIds.length > 0 }))) const cellMenu = useContextMenu() const viewportMenu = useContextMenu() @@ -54,7 +57,7 @@ const selectedIds = ref([]) const showEmpty = computed(() => groupStore.loaded && groupStore.groups.length === 0) -const builder = applyListGridDefaults(CoarGridBuilder.create(), { openable: true }) +const builder = applyListGridDefaults(CoarGridBuilder.create(), { openable: true }) .persistColumnState('admin-groups') .option('getRowId', (p: any) => p.data.Id) .rowDataRef(groups) @@ -68,7 +71,7 @@ const builder = applyListGridDefaults(CoarGridBuilder.create(), { open event.api.deselectAll() event.node.setSelected(true) } - selectedIds.value = event.api.getSelectedRows().map((r: GroupDto) => r.Id) + selectedIds.value = event.api.getSelectedRows().map((r: GroupListRow) => r.Id) cellMenu.open(event.event as MouseEvent) }) .onViewportContextMenu(($event) => { @@ -84,8 +87,10 @@ const builder = applyListGridDefaults(CoarGridBuilder.create(), { open .option('valueGetter', (p: any) => p.data?.MembershipLastError ? 'Error' : p.data?.MembershipMode), (col) => col.field('MemberIds').header('Members', 'admin.groups.members').width(120) .option('valueGetter', (p: any) => (p.data?.MemberIds || []).length), - (col) => col.field('RoleIds').header('Roles', 'admin.groups.roles').width(120) - .option('valueGetter', (p: any) => (p.data?.RoleIds || []).length), + (col) => col.tag('HasPermissions', { + variantMap: { true: 'info', false: 'neutral' }, + i18nPrefix: 'admin.groups.permissionsTag.', + }).header('Permissions', 'admin.groups.permissions').width(180), ]) async function deleteSelected() {