From 1b92075d1597d3b161172e83443769a405bf3bc1 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Thu, 17 Sep 2026 21:13:52 -0400 Subject: [PATCH 1/3] Add Role management: backend CRUD, read models, and UI Team management shipped without Role management alongside it -- the backend had DefineRole/AssignRolePermission/AssignTeamRole aggregate handlers but no HTTP/MCP routes, no DeleteRole, no RemoveRolePermission or RemoveTeamRole, and no read model, so "can I manage roles" was still no. This closes that gap, mirroring the Team management slice's pattern throughout: - New RolePermissionRemoved/TeamRoleRemoved events and DeleteRole/RemoveRolePermission/RemoveTeamRole commands, wired into PermissionProjectionState and PermissionProjector so revoking a permission or unassigning a role from a team actually takes effect in materialized grants (previously only additive assignment was reachable). - Three new KvDirectory-backed read models: RoleDirectory (list/get roles by name), RolePermissionDirectory (list a role's permissions), and RoleTeamDirectory (list the teams holding a role, materialized role-first from the same TeamRoleAssigned/Removed events rbac-team-roles already emits, so a role's detail page doesn't need a substring-search fallback over an unrelated index). - RoleCleanupReactor, mirroring TeamCleanupReactor: deleting a role removes its orphaned permission and team assignments instead of leaving them to rot. - Full HTTP + MCP surface for all of the above, and a /roles, /roles/{roleId} UI (create/delete roles; add/remove permissions and team grants) following the same pages/routing pattern as Teams. Verified end to end against a real Fitz broker: DefineRole, GetRole, and AssignRolePermission (all point reads/writes) work correctly. ListRoles/ListRolePermissions/ListRoleTeams hit the same already- tracked SCAN wire-protocol bug (cntryl/fitz-dotnet#33) that blocked ListTeams before that fix -- not a new regression, the identical pre-existing upstream limitation surfacing on a new read path. --- .../ClientApp/src/api-client/api.ts | 540 ++++++++++++- .../ClientApp/src/api-client/operations.ts | 750 +++++++++++++++++- .../ClientApp/src/api-client/schemas.ts | 29 + .../src/features/roles/pages/role-detail.tsx | 188 +++++ .../src/features/roles/pages/roles-list.tsx | 103 +++ .../ClientApp/src/features/roles/roles.ts | 162 ++++ .../ClientApp/src/pages/_layout.tsx | 3 +- .../ClientApp/src/pages/_routes.ts | 12 + src/Compliance.App/Program.cs | 41 + .../AccessControl/AssignRolePermission.cs | 2 +- .../Features/AccessControl/AssignTeamRole.cs | 4 +- .../Features/AccessControl/DefineRole.cs | 4 +- .../Features/AccessControl/DeleteRole.cs | 7 + .../Features/AccessControl/GetRole.cs | 7 + .../AccessControl/ListRolePermissions.cs | 19 + .../Features/AccessControl/ListRoleTeams.cs | 19 + .../Features/AccessControl/ListRoles.cs | 18 + .../AccessControl/RemoveRolePermission.cs | 11 + .../Features/AccessControl/RemoveTeamRole.cs | 8 + .../AccessControl/RolePermissionRemoved.cs | 6 + .../AccessControl/RolePermissionView.cs | 5 + .../Features/AccessControl/RoleTeamView.cs | 5 + .../Features/AccessControl/RoleView.cs | 5 + .../Features/AccessControl/TeamRoleRemoved.cs | 6 + .../ComplianceCoreJsonContext.cs | 15 + .../ComplianceServiceCollectionExtensions.cs | 17 + .../AccessControl/DeleteRoleHandler.cs | 12 + .../FitzRoleDirectoryProjection.cs | 29 + .../AccessControl/FitzRoleDirectoryReader.cs | 85 ++ .../FitzRolePermissionDirectoryProjection.cs | 34 + .../FitzRolePermissionDirectoryReader.cs | 85 ++ .../FitzRoleTeamDirectoryProjection.cs | 34 + .../FitzRoleTeamDirectoryReader.cs | 85 ++ .../Features/AccessControl/GetRoleHandler.cs | 14 + .../AccessControl/IRoleDirectoryProjection.cs | 9 + .../AccessControl/IRoleDirectoryReader.cs | 19 + .../IRolePermissionDirectoryProjection.cs | 9 + .../IRolePermissionDirectoryReader.cs | 18 + .../IRoleTeamDirectoryProjection.cs | 9 + .../AccessControl/IRoleTeamDirectoryReader.cs | 18 + .../ListRolePermissionsHandler.cs | 22 + .../AccessControl/ListRoleTeamsHandler.cs | 22 + .../AccessControl/ListRolesHandler.cs | 19 + .../PermissionProjectionState.cs | 6 + .../AccessControl/PermissionProjector.cs | 10 +- .../RemoveRolePermissionHandler.cs | 13 + .../AccessControl/RemoveTeamRoleHandler.cs | 12 + .../AccessControl/RoleCleanupReactor.cs | 52 ++ .../AccessControl/RoleDirectoryKeys.cs | 6 + .../AccessControl/RoleDirectoryProjector.cs | 15 + .../AccessControl/RoleDirectorySchema.cs | 20 + .../Features/AccessControl/RolePermission.cs | 9 + .../RolePermissionDirectoryKeys.cs | 6 + .../RolePermissionDirectoryProjector.cs | 15 + .../RolePermissionDirectorySchema.cs | 18 + .../AccessControl/RoleTeamDirectoryKeys.cs | 6 + .../RoleTeamDirectoryProjector.cs | 15 + .../AccessControl/RoleTeamDirectorySchema.cs | 23 + .../Features/AccessControl/TeamRole.cs | 9 + .../DeleteRoleRequestScenarioTests.cs | 73 ++ .../FitzRoleDirectoryReaderTests.cs | 89 +++ .../FitzRolePermissionDirectoryReaderTests.cs | 66 ++ .../FitzRoleTeamDirectoryReaderTests.cs | 54 ++ .../ListRolePermissionsHandlerTests.cs | 76 ++ .../ListRoleTeamsHandlerTests.cs | 77 ++ ...emoveRolePermissionRequestScenarioTests.cs | 74 ++ .../RemoveTeamRoleRequestScenarioTests.cs | 74 ++ .../AccessControl/RoleCleanupReactorTests.cs | 121 +++ .../AccessControl/RolePermissionTests.cs | 84 ++ .../AccessControl/RoleQueryHandlerTests.cs | 102 +++ .../Features/AccessControl/TeamRoleTests.cs | 83 ++ 71 files changed, 3709 insertions(+), 8 deletions(-) create mode 100644 src/Compliance.App/ClientApp/src/features/roles/pages/role-detail.tsx create mode 100644 src/Compliance.App/ClientApp/src/features/roles/pages/roles-list.tsx create mode 100644 src/Compliance.App/ClientApp/src/features/roles/roles.ts create mode 100644 src/Compliance.Common/Features/AccessControl/DeleteRole.cs create mode 100644 src/Compliance.Common/Features/AccessControl/GetRole.cs create mode 100644 src/Compliance.Common/Features/AccessControl/ListRolePermissions.cs create mode 100644 src/Compliance.Common/Features/AccessControl/ListRoleTeams.cs create mode 100644 src/Compliance.Common/Features/AccessControl/ListRoles.cs create mode 100644 src/Compliance.Common/Features/AccessControl/RemoveRolePermission.cs create mode 100644 src/Compliance.Common/Features/AccessControl/RemoveTeamRole.cs create mode 100644 src/Compliance.Common/Features/AccessControl/RolePermissionRemoved.cs create mode 100644 src/Compliance.Common/Features/AccessControl/RolePermissionView.cs create mode 100644 src/Compliance.Common/Features/AccessControl/RoleTeamView.cs create mode 100644 src/Compliance.Common/Features/AccessControl/RoleView.cs create mode 100644 src/Compliance.Common/Features/AccessControl/TeamRoleRemoved.cs create mode 100644 src/Compliance.Core/Features/AccessControl/DeleteRoleHandler.cs create mode 100644 src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryProjection.cs create mode 100644 src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryReader.cs create mode 100644 src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryProjection.cs create mode 100644 src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryReader.cs create mode 100644 src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryProjection.cs create mode 100644 src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryReader.cs create mode 100644 src/Compliance.Core/Features/AccessControl/GetRoleHandler.cs create mode 100644 src/Compliance.Core/Features/AccessControl/IRoleDirectoryProjection.cs create mode 100644 src/Compliance.Core/Features/AccessControl/IRoleDirectoryReader.cs create mode 100644 src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryProjection.cs create mode 100644 src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryReader.cs create mode 100644 src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryProjection.cs create mode 100644 src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryReader.cs create mode 100644 src/Compliance.Core/Features/AccessControl/ListRolePermissionsHandler.cs create mode 100644 src/Compliance.Core/Features/AccessControl/ListRoleTeamsHandler.cs create mode 100644 src/Compliance.Core/Features/AccessControl/ListRolesHandler.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RemoveRolePermissionHandler.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RemoveTeamRoleHandler.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RoleCleanupReactor.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RoleDirectoryKeys.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RoleDirectoryProjector.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RoleDirectorySchema.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryKeys.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryProjector.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RolePermissionDirectorySchema.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryKeys.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryProjector.cs create mode 100644 src/Compliance.Core/Features/AccessControl/RoleTeamDirectorySchema.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/DeleteRoleRequestScenarioTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/FitzRoleDirectoryReaderTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/FitzRolePermissionDirectoryReaderTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/FitzRoleTeamDirectoryReaderTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/ListRolePermissionsHandlerTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/ListRoleTeamsHandlerTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/RemoveRolePermissionRequestScenarioTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/RemoveTeamRoleRequestScenarioTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/RoleCleanupReactorTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/RolePermissionTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/RoleQueryHandlerTests.cs create mode 100644 test/Compliance.Tests/Features/AccessControl/TeamRoleTests.cs diff --git a/src/Compliance.App/ClientApp/src/api-client/api.ts b/src/Compliance.App/ClientApp/src/api-client/api.ts index 464e9b0..7b7be63 100644 --- a/src/Compliance.App/ClientApp/src/api-client/api.ts +++ b/src/Compliance.App/ClientApp/src/api-client/api.ts @@ -1,7 +1,7 @@ import { defineApi, createClient, del, empty, get, json, post } from "@askrjs/fetch"; import type { ClientOptions } from "@askrjs/fetch"; -import type { Portia1EB0255AC1C1D800F4E43175A476BE2EEF9E1FBDC2D4430BCED070CC5D913FAD, Portia534841146AA01FC616B441EB6CC816411AE29E916AB93C8329D97EB96B560E0A, Portia5DC40A4F1CB15D96F9110C5B5E53AA99EDE859D42D78207E7C39CA076C05FE94, Portia70B24E90A73EA0794A4DD21F876262A340BEE644ACBC9B8FF2F0890A79B9D474, PortiaA79F728CB0B620D8928F1E786F7B1B23A937E23A33A65D5C092E345876533FC4, PortiaEA6D5265BAC64B7B7ECBB403179F0802AEF5CDBFF6D32E0FCDF3EF54CB637D54 } from "./schemas"; -import type { AssignTeamMemberPath, DefineTeamPath, DeleteTeamPath, GetTeamPath, ListMyTenantsQuery, ListTeamMembersPath, ListTeamMembersQuery, ListTeamsPath, ListTeamsQuery, RemoveTeamMemberPath, RequestTenantSlugSurrenderPath } from "./operations"; +import type { Portia1EB0255AC1C1D800F4E43175A476BE2EEF9E1FBDC2D4430BCED070CC5D913FAD, Portia411B3239057A525C3562E4BE0D1A71F30A3844A0A56231069471136CDD4A37E4, Portia444702261363EF785C20C78504EBD948CC2950CA076C0FEA93FBD650F83E4680, Portia445823D45DEBC21876B8F7D21A7706F96E8DB18D68A39E931F58C101C313F09E, Portia534841146AA01FC616B441EB6CC816411AE29E916AB93C8329D97EB96B560E0A, Portia5DC40A4F1CB15D96F9110C5B5E53AA99EDE859D42D78207E7C39CA076C05FE94, Portia70B24E90A73EA0794A4DD21F876262A340BEE644ACBC9B8FF2F0890A79B9D474, PortiaA79F728CB0B620D8928F1E786F7B1B23A937E23A33A65D5C092E345876533FC4, PortiaEA6D5265BAC64B7B7ECBB403179F0802AEF5CDBFF6D32E0FCDF3EF54CB637D54, PortiaEC8A68FE610BE733B5FB5E10D0C0A79743573CEEBB9114A5BBD9B4542987EC3B } from "./schemas"; +import type { AssignRolePermissionPath, AssignTeamMemberPath, AssignTeamRolePath, DefineRolePath, DefineTeamPath, DeleteRolePath, DeleteTeamPath, GetRolePath, GetTeamPath, ListMyTenantsQuery, ListRolePermissionsPath, ListRolePermissionsQuery, ListRoleTeamsPath, ListRoleTeamsQuery, ListRolesPath, ListRolesQuery, ListTeamMembersPath, ListTeamMembersQuery, ListTeamsPath, ListTeamsQuery, RemoveRolePermissionPath, RemoveTeamMemberPath, RemoveTeamRolePath, RequestTenantSlugSurrenderPath } from "./operations"; export const api = defineApi({ continueWithDeveloperIdentity: post("/api/v1/developer-user-sessions") @@ -167,6 +167,436 @@ export const api = defineApi({ "detail": string; "instance": string; "transient"?: boolean; +}>() }), + listRoles: get("/api/v1/tenants/{tenantId}/roles") + .params({ "tenantId": { style: "simple", explode: false } }) + .query({ "cursor": { style: "form", explode: true }, "limit": { style: "form", explode: true }, "search": { style: "form", explode: true }, "sort": { style: "form", explode: true } }) + .returns(json()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + getRole: get("/api/v1/tenants/{tenantId}/roles/{roleId}") + .params({ "roleId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .returns(json()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + defineRole: post("/api/v1/tenants/{tenantId}/roles/{roleId}") + .params({ "roleId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .body(json<{ + "name": string | null; +}>()) + .returns(204, empty()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + deleteRole: del("/api/v1/tenants/{tenantId}/roles/{roleId}") + .params({ "roleId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .returns(204, empty()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + listRolePermissions: get("/api/v1/tenants/{tenantId}/roles/{roleId}/permissions") + .params({ "roleId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .query({ "cursor": { style: "form", explode: true }, "limit": { style: "form", explode: true }, "search": { style: "form", explode: true }, "sort": { style: "form", explode: true } }) + .returns(json()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + assignRolePermission: post("/api/v1/tenants/{tenantId}/roles/{roleId}/permissions/{permission}") + .params({ "permission": { style: "simple", explode: false }, "roleId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .returns(204, empty()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + removeRolePermission: del("/api/v1/tenants/{tenantId}/roles/{roleId}/permissions/{permission}") + .params({ "permission": { style: "simple", explode: false }, "roleId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .returns(204, empty()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + listRoleTeams: get("/api/v1/tenants/{tenantId}/roles/{roleId}/teams") + .params({ "roleId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .query({ "cursor": { style: "form", explode: true }, "limit": { style: "form", explode: true }, "search": { style: "form", explode: true }, "sort": { style: "form", explode: true } }) + .returns(json()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; }>() }), requestTenantSlugSurrender: del("/api/v1/tenants/{tenantId}/slugs/{slug}") .params({ "slug": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) @@ -596,6 +1026,112 @@ export const api = defineApi({ "detail": string; "instance": string; "transient"?: boolean; +}>() }), + assignTeamRole: post("/api/v1/tenants/{tenantId}/teams/{teamId}/roles/{roleId}") + .params({ "roleId": { style: "simple", explode: false }, "teamId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .returns(204, empty()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>() }), + removeTeamRole: del("/api/v1/tenants/{tenantId}/teams/{teamId}/roles/{roleId}") + .params({ "roleId": { style: "simple", explode: false }, "teamId": { style: "simple", explode: false }, "tenantId": { style: "simple", explode: false } }) + .returns(204, empty()) + .errors({ "400": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "401": empty(), "403": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "404": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "409": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "413": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "415": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}>(), "500": json<{ + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; }>() }), }, { "servers": [], diff --git a/src/Compliance.App/ClientApp/src/api-client/operations.ts b/src/Compliance.App/ClientApp/src/api-client/operations.ts index abcbab0..9cfd53b 100644 --- a/src/Compliance.App/ClientApp/src/api-client/operations.ts +++ b/src/Compliance.App/ClientApp/src/api-client/operations.ts @@ -1,4 +1,4 @@ -import type { Portia1EB0255AC1C1D800F4E43175A476BE2EEF9E1FBDC2D4430BCED070CC5D913FAD, Portia534841146AA01FC616B441EB6CC816411AE29E916AB93C8329D97EB96B560E0A, Portia5DC40A4F1CB15D96F9110C5B5E53AA99EDE859D42D78207E7C39CA076C05FE94, Portia70B24E90A73EA0794A4DD21F876262A340BEE644ACBC9B8FF2F0890A79B9D474, PortiaA79F728CB0B620D8928F1E786F7B1B23A937E23A33A65D5C092E345876533FC4, PortiaEA6D5265BAC64B7B7ECBB403179F0802AEF5CDBFF6D32E0FCDF3EF54CB637D54 } from "./schemas"; +import type { Portia1EB0255AC1C1D800F4E43175A476BE2EEF9E1FBDC2D4430BCED070CC5D913FAD, Portia411B3239057A525C3562E4BE0D1A71F30A3844A0A56231069471136CDD4A37E4, Portia444702261363EF785C20C78504EBD948CC2950CA076C0FEA93FBD650F83E4680, Portia445823D45DEBC21876B8F7D21A7706F96E8DB18D68A39E931F58C101C313F09E, Portia534841146AA01FC616B441EB6CC816411AE29E916AB93C8329D97EB96B560E0A, Portia5DC40A4F1CB15D96F9110C5B5E53AA99EDE859D42D78207E7C39CA076C05FE94, Portia70B24E90A73EA0794A4DD21F876262A340BEE644ACBC9B8FF2F0890A79B9D474, PortiaA79F728CB0B620D8928F1E786F7B1B23A937E23A33A65D5C092E345876533FC4, PortiaEA6D5265BAC64B7B7ECBB403179F0802AEF5CDBFF6D32E0FCDF3EF54CB637D54, PortiaEC8A68FE610BE733B5FB5E10D0C0A79743573CEEBB9114A5BBD9B4542987EC3B } from "./schemas"; export type ContinueWithDeveloperIdentityBody = { "email_address": string | null; @@ -215,6 +215,608 @@ export type ListMyTenantsError_500 = { "transient"?: boolean; }; +export type ListRolesPath = { + "tenantId": string; +}; + +export type ListRolesQuery = { + "cursor"?: string | null; + "limit"?: number | string; + "search"?: string | null; + "sort"?: string | null; +}; + +export type ListRolesResponse200 = Portia411B3239057A525C3562E4BE0D1A71F30A3844A0A56231069471136CDD4A37E4; + +export type ListRolesError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolesError_401 = undefined; + +export type ListRolesError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolesError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolesError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolesError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolesError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolesError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type GetRolePath = { + "roleId": string; + "tenantId": string; +}; + +export type GetRoleResponse200 = Portia444702261363EF785C20C78504EBD948CC2950CA076C0FEA93FBD650F83E4680; + +export type GetRoleError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type GetRoleError_401 = undefined; + +export type GetRoleError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type GetRoleError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type GetRoleError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type GetRoleError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type GetRoleError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type GetRoleError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DefineRolePath = { + "roleId": string; + "tenantId": string; +}; + +export type DefineRoleBody = { + "name": string | null; +}; + +export type DefineRoleResponse204 = undefined; + +export type DefineRoleError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DefineRoleError_401 = undefined; + +export type DefineRoleError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DefineRoleError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DefineRoleError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DefineRoleError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DefineRoleError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DefineRoleError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DeleteRolePath = { + "roleId": string; + "tenantId": string; +}; + +export type DeleteRoleResponse204 = undefined; + +export type DeleteRoleError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DeleteRoleError_401 = undefined; + +export type DeleteRoleError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DeleteRoleError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DeleteRoleError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DeleteRoleError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DeleteRoleError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type DeleteRoleError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolePermissionsPath = { + "roleId": string; + "tenantId": string; +}; + +export type ListRolePermissionsQuery = { + "cursor"?: string | null; + "limit"?: number | string; + "search"?: string | null; + "sort"?: string | null; +}; + +export type ListRolePermissionsResponse200 = PortiaEC8A68FE610BE733B5FB5E10D0C0A79743573CEEBB9114A5BBD9B4542987EC3B; + +export type ListRolePermissionsError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolePermissionsError_401 = undefined; + +export type ListRolePermissionsError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolePermissionsError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolePermissionsError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolePermissionsError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolePermissionsError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRolePermissionsError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignRolePermissionPath = { + "permission": string | null; + "roleId": string; + "tenantId": string; +}; + +export type AssignRolePermissionResponse204 = undefined; + +export type AssignRolePermissionError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignRolePermissionError_401 = undefined; + +export type AssignRolePermissionError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignRolePermissionError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignRolePermissionError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignRolePermissionError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignRolePermissionError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignRolePermissionError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveRolePermissionPath = { + "permission": string | null; + "roleId": string; + "tenantId": string; +}; + +export type RemoveRolePermissionResponse204 = undefined; + +export type RemoveRolePermissionError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveRolePermissionError_401 = undefined; + +export type RemoveRolePermissionError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveRolePermissionError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveRolePermissionError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveRolePermissionError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveRolePermissionError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveRolePermissionError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRoleTeamsPath = { + "roleId": string; + "tenantId": string; +}; + +export type ListRoleTeamsQuery = { + "cursor"?: string | null; + "limit"?: number | string; + "search"?: string | null; + "sort"?: string | null; +}; + +export type ListRoleTeamsResponse200 = Portia445823D45DEBC21876B8F7D21A7706F96E8DB18D68A39E931F58C101C313F09E; + +export type ListRoleTeamsError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRoleTeamsError_401 = undefined; + +export type ListRoleTeamsError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRoleTeamsError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRoleTeamsError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRoleTeamsError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRoleTeamsError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type ListRoleTeamsError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + export type RequestTenantSlugSurrenderPath = { "slug": string | null; "tenantId": string; @@ -809,3 +1411,149 @@ export type RemoveTeamMemberError_500 = { "instance": string; "transient"?: boolean; }; + +export type AssignTeamRolePath = { + "roleId": string; + "teamId": string; + "tenantId": string; +}; + +export type AssignTeamRoleResponse204 = undefined; + +export type AssignTeamRoleError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignTeamRoleError_401 = undefined; + +export type AssignTeamRoleError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignTeamRoleError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignTeamRoleError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignTeamRoleError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignTeamRoleError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type AssignTeamRoleError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveTeamRolePath = { + "roleId": string; + "teamId": string; + "tenantId": string; +}; + +export type RemoveTeamRoleResponse204 = undefined; + +export type RemoveTeamRoleError_400 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveTeamRoleError_401 = undefined; + +export type RemoveTeamRoleError_403 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveTeamRoleError_404 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveTeamRoleError_409 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveTeamRoleError_413 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveTeamRoleError_415 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; + +export type RemoveTeamRoleError_500 = { + "type": string; + "title": string; + "status": number; + "detail": string; + "instance": string; + "transient"?: boolean; +}; diff --git a/src/Compliance.App/ClientApp/src/api-client/schemas.ts b/src/Compliance.App/ClientApp/src/api-client/schemas.ts index a7bc6c9..f069e9b 100644 --- a/src/Compliance.App/ClientApp/src/api-client/schemas.ts +++ b/src/Compliance.App/ClientApp/src/api-client/schemas.ts @@ -3,6 +3,27 @@ export type Portia1EB0255AC1C1D800F4E43175A476BE2EEF9E1FBDC2D4430BCED070CC5D913F "name": string; } | null; +export type Portia411B3239057A525C3562E4BE0D1A71F30A3844A0A56231069471136CDD4A37E4 = { + "items": Array<{ + "role_id": string; + "name": string; +} | null>; + "next_cursor": string | null; +} | null; + +export type Portia444702261363EF785C20C78504EBD948CC2950CA076C0FEA93FBD650F83E4680 = { + "role_id": string; + "name": string; +} | null; + +export type Portia445823D45DEBC21876B8F7D21A7706F96E8DB18D68A39E931F58C101C313F09E = { + "items": Array<{ + "role_id": string; + "team_id": string; +} | null>; + "next_cursor": string | null; +} | null; + export type Portia534841146AA01FC616B441EB6CC816411AE29E916AB93C8329D97EB96B560E0A = { "items": Array<{ "team_id": string; @@ -38,3 +59,11 @@ export type PortiaEA6D5265BAC64B7B7ECBB403179F0802AEF5CDBFF6D32E0FCDF3EF54CB637D } | null>; "next_cursor": string | null; } | null; + +export type PortiaEC8A68FE610BE733B5FB5E10D0C0A79743573CEEBB9114A5BBD9B4542987EC3B = { + "items": Array<{ + "role_id": string; + "permission": string; +} | null>; + "next_cursor": string | null; +} | null; diff --git a/src/Compliance.App/ClientApp/src/features/roles/pages/role-detail.tsx b/src/Compliance.App/ClientApp/src/features/roles/pages/role-detail.tsx new file mode 100644 index 0000000..10421cd --- /dev/null +++ b/src/Compliance.App/ClientApp/src/features/roles/pages/role-detail.tsx @@ -0,0 +1,188 @@ +import { state } from '@askrjs/askr'; +import { + Block, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Page, + PageHeader, +} from '@askrjs/themes/components'; + +import { + assignRolePermission, + assignTeamRole, + getRole, + listRolePermissions, + listRoleTeams, + removeRolePermission, + removeTeamRole, + type RolePermissionSummary, + type RoleSummary, + type RoleTeamSummary, +} from '../roles.js'; + +export function RoleDetailPage({ roleId }: { roleId: string }) { + const [role, setRole] = state(null); + const [permissions, setPermissions] = state(null); + const [teams, setTeams] = state(null); + const [permission, setPermission] = state(''); + const [teamId, setTeamId] = state(''); + const [error, setError] = state(null); + const [submittingPermission, setSubmittingPermission] = state(false); + const [submittingTeam, setSubmittingTeam] = state(false); + + function load() { + void getRole(roleId) + .then(setRole) + .catch((failure: unknown) => + setError(failure instanceof Error ? failure.message : 'Unable to load the role.') + ); + void listRolePermissions(roleId) + .then(setPermissions) + .catch((failure: unknown) => + setError(failure instanceof Error ? failure.message : 'Unable to load the role permissions.') + ); + void listRoleTeams(roleId) + .then(setTeams) + .catch((failure: unknown) => + setError(failure instanceof Error ? failure.message : 'Unable to load the role teams.') + ); + } + + load(); + + async function addPermission(event?: { preventDefault?: () => void }) { + event?.preventDefault?.(); + setError(null); + setSubmittingPermission(true); + try { + await assignRolePermission(roleId, permission()); + setPermission(''); + load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Unable to add that permission.'); + } finally { + setSubmittingPermission(false); + } + } + + async function removePermission(targetPermission: string) { + setError(null); + try { + await removeRolePermission(roleId, targetPermission); + load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Unable to remove that permission.'); + } + } + + async function addTeam(event?: { preventDefault?: () => void }) { + event?.preventDefault?.(); + setError(null); + setSubmittingTeam(true); + try { + await assignTeamRole(roleId, teamId()); + setTeamId(''); + load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Unable to grant that team this role.'); + } finally { + setSubmittingTeam(false); + } + } + + async function removeTeam(targetTeamId: string) { + setError(null); + try { + await removeTeamRole(roleId, targetTeamId); + load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Unable to remove that team.'); + } + } + + return ( + + + {error() ?

{error()}

: null} + + + Permissions + + +
void addPermission(event)}> + + setPermission((event.target as HTMLInputElement).value)} + required + /> + + +
+ {permissions() === null ? ( +

Loading…

+ ) : ( + + {(permissions() ?? []).map((item) => ( + + {item.permission} + + + ))} + + )} +
+
+ + + Teams + + +
void addTeam(event)}> + + setTeamId((event.target as HTMLInputElement).value)} + required + /> + + +
+ {teams() === null ? ( +

Loading…

+ ) : ( + + {(teams() ?? []).map((item) => ( + + {item.teamId} + + + ))} + + )} +
+
+ +
+ ); +} diff --git a/src/Compliance.App/ClientApp/src/features/roles/pages/roles-list.tsx b/src/Compliance.App/ClientApp/src/features/roles/pages/roles-list.tsx new file mode 100644 index 0000000..741cf05 --- /dev/null +++ b/src/Compliance.App/ClientApp/src/features/roles/pages/roles-list.tsx @@ -0,0 +1,103 @@ +import { state } from '@askrjs/askr'; +import { + Block, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Page, + PageHeader, +} from '@askrjs/themes/components'; + +import { defineRole, deleteRole, listRoles, type RoleSummary } from '../roles.js'; + +export function RolesListPage() { + const [roles, setRoles] = state(null); + const [name, setName] = state(''); + const [error, setError] = state(null); + const [submitting, setSubmitting] = state(false); + + function load() { + void listRoles() + .then(setRoles) + .catch((failure: unknown) => + setError(failure instanceof Error ? failure.message : 'Unable to load roles.') + ); + } + + load(); + + async function create(event?: { preventDefault?: () => void }) { + event?.preventDefault?.(); + setError(null); + setSubmitting(true); + try { + await defineRole(name()); + setName(''); + load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Unable to create the role.'); + } finally { + setSubmitting(false); + } + } + + async function remove(roleId: string) { + setError(null); + try { + await deleteRole(roleId); + load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Unable to delete the role.'); + } + } + + return ( + + + + + New role + + +
void create(event)}> + + setName((event.target as HTMLInputElement).value)} + required + /> + + +
+
+
+ {error() ?

{error()}

: null} + {roles() === null && !error() ?

Loading…

: null} + + {(roles() ?? []).map((role) => ( + + + + + + + + + ))} + +
+ ); +} diff --git a/src/Compliance.App/ClientApp/src/features/roles/roles.ts b/src/Compliance.App/ClientApp/src/features/roles/roles.ts new file mode 100644 index 0000000..8699859 --- /dev/null +++ b/src/Compliance.App/ClientApp/src/features/roles/roles.ts @@ -0,0 +1,162 @@ +import { createApiClient } from '../../api-client/index.js'; +import { readActiveTenant } from '../tenants/tenants.js'; + +const client = createApiClient(); + +export interface RoleSummary { + roleId: string; + name: string; +} + +export interface RolePermissionSummary { + permission: string; +} + +export interface RoleTeamSummary { + teamId: string; +} + +function requireTenantId(): string { + const tenant = readActiveTenant(); + if (!tenant) { + // Shouldn't happen in normal flow -- every authenticated page routes through + // ensureActiveTenant() first. Send the user back to have that resolved again. + window.location.assign('/'); + throw new Error('No active organization is selected.'); + } + + return tenant.tenantId; +} + +export async function listRoles(): Promise { + const tenantId = requireTenantId(); + const roles: RoleSummary[] = []; + let cursor: string | undefined; + do { + const result = await client.listRoles({ params: { tenantId }, query: { cursor } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } + + for (const item of result.data?.items ?? []) { + if (item) { + roles.push({ roleId: item.role_id, name: item.name }); + } + } + + cursor = result.data?.next_cursor ?? undefined; + } while (cursor !== undefined); + + return roles; +} + +export async function getRole(roleId: string): Promise { + const tenantId = requireTenantId(); + const result = await client.getRole({ params: { tenantId, roleId } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } + + return result.data ? { roleId: result.data.role_id, name: result.data.name } : null; +} + +export async function defineRole(name: string): Promise { + const tenantId = requireTenantId(); + const roleId = crypto.randomUUID(); + const result = await client.defineRole({ params: { tenantId, roleId }, body: { name } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } + + return roleId; +} + +export async function deleteRole(roleId: string): Promise { + const tenantId = requireTenantId(); + const result = await client.deleteRole({ params: { tenantId, roleId } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } +} + +export async function listRolePermissions(roleId: string): Promise { + const tenantId = requireTenantId(); + const permissions: RolePermissionSummary[] = []; + let cursor: string | undefined; + do { + const result = await client.listRolePermissions({ params: { tenantId, roleId }, query: { cursor } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } + + for (const item of result.data?.items ?? []) { + if (item) { + permissions.push({ permission: item.permission }); + } + } + + cursor = result.data?.next_cursor ?? undefined; + } while (cursor !== undefined); + + return permissions; +} + +export async function assignRolePermission(roleId: string, permission: string): Promise { + const tenantId = requireTenantId(); + const result = await client.assignRolePermission({ params: { tenantId, roleId, permission } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } +} + +export async function removeRolePermission(roleId: string, permission: string): Promise { + const tenantId = requireTenantId(); + const result = await client.removeRolePermission({ params: { tenantId, roleId, permission } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } +} + +export async function listRoleTeams(roleId: string): Promise { + const tenantId = requireTenantId(); + const teams: RoleTeamSummary[] = []; + let cursor: string | undefined; + do { + const result = await client.listRoleTeams({ params: { tenantId, roleId }, query: { cursor } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } + + for (const item of result.data?.items ?? []) { + if (item) { + teams.push({ teamId: item.team_id }); + } + } + + cursor = result.data?.next_cursor ?? undefined; + } while (cursor !== undefined); + + return teams; +} + +export async function assignTeamRole(roleId: string, teamId: string): Promise { + const tenantId = requireTenantId(); + const result = await client.assignTeamRole({ params: { tenantId, teamId, roleId } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } +} + +export async function removeTeamRole(roleId: string, teamId: string): Promise { + const tenantId = requireTenantId(); + const result = await client.removeTeamRole({ params: { tenantId, teamId, roleId } }); + if (!result.ok) { + throw new Error(describeFailure(result)); + } +} + +function describeFailure(result: { ok: false; kind: string; status?: number }): string { + return result.kind === 'http' + ? `The request failed (${result.status ?? 'unknown status'}).` + : 'The request could not be completed.'; +} diff --git a/src/Compliance.App/ClientApp/src/pages/_layout.tsx b/src/Compliance.App/ClientApp/src/pages/_layout.tsx index 2545456..edb55a3 100644 --- a/src/Compliance.App/ClientApp/src/pages/_layout.tsx +++ b/src/Compliance.App/ClientApp/src/pages/_layout.tsx @@ -1,5 +1,5 @@ import { Link, currentAuth, currentRoute } from '@askrjs/askr/router'; -import { HomeIcon, LogOutIcon, MoonIcon, SunIcon, UsersIcon } from '@askrjs/lucide'; +import { HomeIcon, LogOutIcon, MoonIcon, ShieldIcon, SunIcon, UsersIcon } from '@askrjs/lucide'; import { Block, Button, @@ -27,6 +27,7 @@ import { signOut } from '../features/authentication/auth.js'; const primaryNavLinks = [ { title: 'Overview', href: '/', icon: HomeIcon, exact: true }, { title: 'Teams', href: '/teams', icon: UsersIcon, exact: false }, + { title: 'Roles', href: '/roles', icon: ShieldIcon, exact: false }, ]; function isActiveNavLink(currentPath: string, href: string, exact: boolean) { diff --git a/src/Compliance.App/ClientApp/src/pages/_routes.ts b/src/Compliance.App/ClientApp/src/pages/_routes.ts index f24d6f8..c04591c 100644 --- a/src/Compliance.App/ClientApp/src/pages/_routes.ts +++ b/src/Compliance.App/ClientApp/src/pages/_routes.ts @@ -48,6 +48,16 @@ const TeamDetailPage = lazy(() => (module) => module.TeamDetailPage ) ); +const RolesListPage = lazy(() => + import('../features/roles/pages/roles-list.js').then( + (module) => module.RolesListPage + ) +); +const RoleDetailPage = lazy(() => + import('../features/roles/pages/role-detail.js').then( + (module) => module.RoleDetailPage + ) +); export const pageRegistry = createRouteRegistry( () => { @@ -68,6 +78,8 @@ export const pageRegistry = createRouteRegistry( route('/organizations/new', CreateTenantPage); route('/teams', TeamsListPage); route('/teams/{teamId}', TeamDetailPage); + route('/roles', RolesListPage); + route('/roles/{roleId}', RoleDetailPage); route('/*', NotFoundPage); }); }); diff --git a/src/Compliance.App/Program.cs b/src/Compliance.App/Program.cs index c81c810..4d84b33 100644 --- a/src/Compliance.App/Program.cs +++ b/src/Compliance.App/Program.cs @@ -37,6 +37,16 @@ static async Task RunApiAsync(string[] args, ComplianceHostMode hostMode) .AddMcpTool(tool => tool.Idempotent()) .AddMcpTool(tool => tool.Destructive()) .AddMcpTool(tool => tool.ReadOnly()) + .AddMcpTool(tool => tool.Idempotent()) + .AddMcpTool(tool => tool.Destructive()) + .AddMcpTool(tool => tool.ReadOnly()) + .AddMcpTool(tool => tool.ReadOnly()) + .AddMcpTool(tool => tool.Idempotent()) + .AddMcpTool(tool => tool.Destructive()) + .AddMcpTool(tool => tool.ReadOnly()) + .AddMcpTool(tool => tool.Idempotent()) + .AddMcpTool(tool => tool.Destructive()) + .AddMcpTool(tool => tool.ReadOnly()) .AddMcpTool(tool => tool.ReadOnly()) .AddMcpHttp(); builder.Services.ConfigureHttpJsonOptions(options => @@ -152,6 +162,37 @@ static async Task RunApiAsync(string[] args, ComplianceHostMode hostMode) app.MapPortiaGet>("/api/v1/tenants/{tenantId}/teams/{teamId}/members") .RequireAuthorization() .WithTags("Teams"); + app.MapPortiaPost("/api/v1/tenants/{tenantId}/teams/{teamId}/roles/{roleId}") + .RequireAuthorization() + .WithTags("Teams"); + app.MapPortiaDelete("/api/v1/tenants/{tenantId}/teams/{teamId}/roles/{roleId}") + .RequireAuthorization() + .WithTags("Teams"); + app.MapPortiaPost("/api/v1/tenants/{tenantId}/roles/{roleId}") + .RequireAuthorization() + .WithTags("Roles"); + app.MapPortiaDelete("/api/v1/tenants/{tenantId}/roles/{roleId}") + .RequireAuthorization() + .WithTags("Roles"); + app.MapPortiaGet("/api/v1/tenants/{tenantId}/roles/{roleId}") + .RequireAuthorization() + .WithTags("Roles"); + app.MapPortiaGet>("/api/v1/tenants/{tenantId}/roles") + .RequireAuthorization() + .WithTags("Roles"); + app.MapPortiaPost("/api/v1/tenants/{tenantId}/roles/{roleId}/permissions/{permission}") + .RequireAuthorization() + .WithTags("Roles"); + app.MapPortiaDelete("/api/v1/tenants/{tenantId}/roles/{roleId}/permissions/{permission}") + .RequireAuthorization() + .WithTags("Roles"); + app.MapPortiaGet>( + "/api/v1/tenants/{tenantId}/roles/{roleId}/permissions") + .RequireAuthorization() + .WithTags("Roles"); + app.MapPortiaGet>("/api/v1/tenants/{tenantId}/roles/{roleId}/teams") + .RequireAuthorization() + .WithTags("Roles"); app.MapMethods( "/api/{**path}", ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"], diff --git a/src/Compliance.Common/Features/AccessControl/AssignRolePermission.cs b/src/Compliance.Common/Features/AccessControl/AssignRolePermission.cs index 242e99a..ba42119 100644 --- a/src/Compliance.Common/Features/AccessControl/AssignRolePermission.cs +++ b/src/Compliance.Common/Features/AccessControl/AssignRolePermission.cs @@ -7,4 +7,4 @@ namespace Bdgrz.Compliance.Features.AccessControl; Justification = "AssignRolePermission uses the canonical RBAC relationship term.")] [Discriminator("bdgrz.rbac.role-permission.assign", 1)] public sealed record AssignRolePermission(Uuid TenantId, Uuid RoleId, string Permission) - : IRequest, IRbacManagementRequest; + : IRequest, IRbacManagementRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/AssignTeamRole.cs b/src/Compliance.Common/Features/AccessControl/AssignTeamRole.cs index 4c81465..4c19394 100644 --- a/src/Compliance.Common/Features/AccessControl/AssignTeamRole.cs +++ b/src/Compliance.Common/Features/AccessControl/AssignTeamRole.cs @@ -2,5 +2,7 @@ namespace Bdgrz.Compliance.Features.AccessControl; +/// Assigns a role to a team, adding it if it is not already assigned. [Discriminator("bdgrz.rbac.team-role.assign", 1)] -public sealed record AssignTeamRole(Uuid TenantId, Uuid TeamId, Uuid RoleId) : IRequest, IRbacManagementRequest; +public sealed record AssignTeamRole(Uuid TenantId, Uuid TeamId, Uuid RoleId) + : IRequest, IRbacManagementRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/DefineRole.cs b/src/Compliance.Common/Features/AccessControl/DefineRole.cs index 3e73edb..3d05fe0 100644 --- a/src/Compliance.Common/Features/AccessControl/DefineRole.cs +++ b/src/Compliance.Common/Features/AccessControl/DefineRole.cs @@ -2,5 +2,7 @@ namespace Bdgrz.Compliance.Features.AccessControl; +/// Defines a role within a tenant, creating it if it does not already exist. [Discriminator("bdgrz.rbac.role.define", 1)] -public sealed record DefineRole(Uuid TenantId, Uuid RoleId, string Name) : IRequest, IRbacManagementRequest; +public sealed record DefineRole(Uuid TenantId, Uuid RoleId, string Name) + : IRequest, IRbacManagementRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/DeleteRole.cs b/src/Compliance.Common/Features/AccessControl/DeleteRole.cs new file mode 100644 index 0000000..68f1154 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/DeleteRole.cs @@ -0,0 +1,7 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Deletes a role from a tenant. Built-in roles cannot be deleted. +[Discriminator("bdgrz.rbac.role.delete", 1)] +public sealed record DeleteRole(Uuid TenantId, Uuid RoleId) : IRequest, IRbacManagementRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/GetRole.cs b/src/Compliance.Common/Features/AccessControl/GetRole.cs new file mode 100644 index 0000000..70e4fa4 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/GetRole.cs @@ -0,0 +1,7 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Reads one role by id. +[Discriminator("bdgrz.rbac.role.get", 1)] +public sealed record GetRole(Uuid TenantId, Uuid RoleId) : IRequest, ITenantAccessRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/ListRolePermissions.cs b/src/Compliance.Common/Features/AccessControl/ListRolePermissions.cs new file mode 100644 index 0000000..a26b951 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/ListRolePermissions.cs @@ -0,0 +1,19 @@ +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// Lists a role's permissions. Limit defaults to 50 and must be between 1 and 200; +/// Cursor resumes a previous page; Search filters by a case-insensitive +/// permission substring; Sort is "permission" or "permission:desc" — +/// permissions have exactly one index, so anything containing "desc" reverses it. +/// +[Discriminator("bdgrz.rbac.role-permission.list", 1)] +public sealed record ListRolePermissions( + Uuid TenantId, + Uuid RoleId, + int? Limit = null, + string? Cursor = null, + string? Search = null, + string? Sort = null) : IRequest>, ITenantAccessRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/ListRoleTeams.cs b/src/Compliance.Common/Features/AccessControl/ListRoleTeams.cs new file mode 100644 index 0000000..b798082 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/ListRoleTeams.cs @@ -0,0 +1,19 @@ +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// Lists the teams a role is assigned to. Limit defaults to 50 and must be between 1 and +/// 200; Cursor resumes a previous page; Search filters by a case-insensitive +/// team-ID substring; Sort is "team_id" or "team_id:desc" — teams have +/// exactly one index, so anything containing "desc" reverses it. +/// +[Discriminator("bdgrz.rbac.role-team.list", 1)] +public sealed record ListRoleTeams( + Uuid TenantId, + Uuid RoleId, + int? Limit = null, + string? Cursor = null, + string? Search = null, + string? Sort = null) : IRequest>, ITenantAccessRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/ListRoles.cs b/src/Compliance.Common/Features/AccessControl/ListRoles.cs new file mode 100644 index 0000000..18dcca2 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/ListRoles.cs @@ -0,0 +1,18 @@ +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// Lists a tenant's roles by name. Limit defaults to 50 and must be between 1 and 200; +/// Cursor resumes a previous page; Search filters by a case-insensitive name +/// substring; Sort is "name" or "name:desc" — roles have exactly one index, +/// so anything containing "desc" reverses it. +/// +[Discriminator("bdgrz.rbac.role.list", 1)] +public sealed record ListRoles( + Uuid TenantId, + int? Limit = null, + string? Cursor = null, + string? Search = null, + string? Sort = null) : IRequest>, ITenantAccessRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/RemoveRolePermission.cs b/src/Compliance.Common/Features/AccessControl/RemoveRolePermission.cs new file mode 100644 index 0000000..c082068 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/RemoveRolePermission.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Removes a permission from a role. +[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", + Justification = "RemoveRolePermission uses the canonical RBAC relationship term.")] +[Discriminator("bdgrz.rbac.role-permission.remove", 1)] +public sealed record RemoveRolePermission(Uuid TenantId, Uuid RoleId, string Permission) + : IRequest, IRbacManagementRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/RemoveTeamRole.cs b/src/Compliance.Common/Features/AccessControl/RemoveTeamRole.cs new file mode 100644 index 0000000..255e1c4 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/RemoveTeamRole.cs @@ -0,0 +1,8 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Removes a role from a team. +[Discriminator("bdgrz.rbac.team-role.remove", 1)] +public sealed record RemoveTeamRole(Uuid TenantId, Uuid TeamId, Uuid RoleId) + : IRequest, IRbacManagementRequest, ICallable; diff --git a/src/Compliance.Common/Features/AccessControl/RolePermissionRemoved.cs b/src/Compliance.Common/Features/AccessControl/RolePermissionRemoved.cs new file mode 100644 index 0000000..99866c3 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/RolePermissionRemoved.cs @@ -0,0 +1,6 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +[Discriminator("bdgrz.rbac.role-permission.removed", 1)] +public sealed record RolePermissionRemoved(Uuid TenantId, Uuid RoleId, string Permission) : DomainEvent; diff --git a/src/Compliance.Common/Features/AccessControl/RolePermissionView.cs b/src/Compliance.Common/Features/AccessControl/RolePermissionView.cs new file mode 100644 index 0000000..78f781b --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/RolePermissionView.cs @@ -0,0 +1,5 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed record RolePermissionView(Uuid RoleId, string Permission); diff --git a/src/Compliance.Common/Features/AccessControl/RoleTeamView.cs b/src/Compliance.Common/Features/AccessControl/RoleTeamView.cs new file mode 100644 index 0000000..c77dbce --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/RoleTeamView.cs @@ -0,0 +1,5 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed record RoleTeamView(Uuid RoleId, Uuid TeamId); diff --git a/src/Compliance.Common/Features/AccessControl/RoleView.cs b/src/Compliance.Common/Features/AccessControl/RoleView.cs new file mode 100644 index 0000000..42b9252 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/RoleView.cs @@ -0,0 +1,5 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed record RoleView(Uuid RoleId, string Name); diff --git a/src/Compliance.Common/Features/AccessControl/TeamRoleRemoved.cs b/src/Compliance.Common/Features/AccessControl/TeamRoleRemoved.cs new file mode 100644 index 0000000..c0cf3c5 --- /dev/null +++ b/src/Compliance.Common/Features/AccessControl/TeamRoleRemoved.cs @@ -0,0 +1,6 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +[Discriminator("bdgrz.rbac.team-role.removed", 1)] +public sealed record TeamRoleRemoved(Uuid TenantId, Uuid TeamId, Uuid RoleId) : DomainEvent; diff --git a/src/Compliance.Core/ComplianceCoreJsonContext.cs b/src/Compliance.Core/ComplianceCoreJsonContext.cs index 247a294..a726a2d 100644 --- a/src/Compliance.Core/ComplianceCoreJsonContext.cs +++ b/src/Compliance.Core/ComplianceCoreJsonContext.cs @@ -51,10 +51,23 @@ namespace Bdgrz.Compliance; [JsonSerializable(typeof(TeamMemberView))] [JsonSerializable(typeof(Page))] [JsonSerializable(typeof(DefineRole))] +[JsonSerializable(typeof(DeleteRole))] +[JsonSerializable(typeof(GetRole))] +[JsonSerializable(typeof(ListRoles))] +[JsonSerializable(typeof(RoleView))] +[JsonSerializable(typeof(Page))] +[JsonSerializable(typeof(ListRolePermissions))] +[JsonSerializable(typeof(RolePermissionView))] +[JsonSerializable(typeof(Page))] +[JsonSerializable(typeof(ListRoleTeams))] +[JsonSerializable(typeof(RoleTeamView))] +[JsonSerializable(typeof(Page))] [JsonSerializable(typeof(AssignTeamMember))] [JsonSerializable(typeof(RemoveTeamMember))] [JsonSerializable(typeof(AssignTeamRole))] +[JsonSerializable(typeof(RemoveTeamRole))] [JsonSerializable(typeof(AssignRolePermission))] +[JsonSerializable(typeof(RemoveRolePermission))] [JsonSerializable(typeof(TeamDefined))] [JsonSerializable(typeof(TeamDeleted))] [JsonSerializable(typeof(TeamMemberAssigned))] @@ -62,7 +75,9 @@ namespace Bdgrz.Compliance; [JsonSerializable(typeof(RoleDefined))] [JsonSerializable(typeof(RoleDeleted))] [JsonSerializable(typeof(RolePermissionAssigned))] +[JsonSerializable(typeof(RolePermissionRemoved))] [JsonSerializable(typeof(TeamRoleAssigned))] +[JsonSerializable(typeof(TeamRoleRemoved))] [JsonSerializable(typeof(PermissionProjectionState))] [JsonSerializable(typeof(string))] sealed partial class ComplianceCoreJsonContext : JsonSerializerContext; diff --git a/src/Compliance.Core/ComplianceServiceCollectionExtensions.cs b/src/Compliance.Core/ComplianceServiceCollectionExtensions.cs index 9cae472..8db8197 100644 --- a/src/Compliance.Core/ComplianceServiceCollectionExtensions.cs +++ b/src/Compliance.Core/ComplianceServiceCollectionExtensions.cs @@ -28,6 +28,12 @@ public static PortiaBuilder AddCompliance( services.AddSingleton(); services.AddScoped(); services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddSingleton(); services.AddScoped(); @@ -46,14 +52,21 @@ public static PortiaBuilder AddCompliance( .AddRequestHandler() .AddRequestHandler() .AddRequestHandler() + .AddRequestHandler() .AddRequestHandler() .AddRequestHandler() .AddRequestHandler() + .AddRequestHandler() .AddRequestHandler() + .AddRequestHandler() .AddRequestAuthorizer() .AddRequestHandler() .AddRequestHandler() .AddRequestHandler() + .AddRequestHandler() + .AddRequestHandler() + .AddRequestHandler() + .AddRequestHandler() .AddRequestAuthorizer() .AddRequestHandler() .AddRequestAuthorizer() @@ -72,9 +85,13 @@ public static PortiaBuilder AddCompliance( .AddReactor("TenantRbacBootstrap", WorkloadScope.Global) .AddReactor("TenantSlug", WorkloadScope.Global) .AddReactor("TeamCleanup", WorkloadScope.PerTenant) + .AddReactor("RoleCleanup", WorkloadScope.PerTenant) .AddProjector("PermissionProjection", WorkloadScope.PerTenant) .AddProjector("TeamDirectory", WorkloadScope.PerTenant) .AddProjector("TeamMemberDirectory", WorkloadScope.PerTenant) + .AddProjector("RoleDirectory", WorkloadScope.PerTenant) + .AddProjector("RolePermissionDirectory", WorkloadScope.PerTenant) + .AddProjector("RoleTeamDirectory", WorkloadScope.PerTenant) .AddProjector("TenantDirectory", WorkloadScope.Global) .AddProjector("TenantMembership", WorkloadScope.PerTenant) .AddFitz( diff --git a/src/Compliance.Core/Features/AccessControl/DeleteRoleHandler.cs b/src/Compliance.Core/Features/AccessControl/DeleteRoleHandler.cs new file mode 100644 index 0000000..e458106 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/DeleteRoleHandler.cs @@ -0,0 +1,12 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed class DeleteRoleHandler(IAggregateExecutor executor) : IRequestHandler +{ + public ValueTask HandleAsync(IRequestContext context, CancellationToken ct) => + executor.ExecuteAsync( + new Role(context.Request.TenantId, context.Request.RoleId), + role => AggregateOutcome.CommitOnSuccess(role.Delete()), + context, ct); +} diff --git a/src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryProjection.cs b/src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryProjection.cs new file mode 100644 index 0000000..c192a40 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryProjection.cs @@ -0,0 +1,29 @@ +using Cntryl.Fitz; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +sealed class FitzRoleDirectoryProjection(IKvClient client, WorkloadContext workload) + : FitzKvProjectionStore(client, Route(workload)), IRoleDirectoryProjection +{ + public async ValueTask ApplyAsync(DomainEvent domainEvent, CancellationToken ct = default) + { + switch (domainEvent) + { + case RoleDefined defined: + await RoleDirectorySchema.Directory.InsertAsync( + Transaction, new RoleView(defined.RoleId, defined.Name), ct).ConfigureAwait(false); + break; + case RoleDeleted deleted: + await RoleDirectorySchema.Directory.DeleteAsync(Transaction, deleted.RoleId, ct).ConfigureAwait(false); + break; + } + } + + static string Route(WorkloadContext workload) + { + var tenant = workload.Identity.Tenant + ?? throw new InvalidOperationException("The role directory projection requires a tenant workload."); + return RoleDirectoryKeys.Route(tenant.Value); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryReader.cs b/src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryReader.cs new file mode 100644 index 0000000..087a42d --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/FitzRoleDirectoryReader.cs @@ -0,0 +1,85 @@ +using Cntryl.Fitz; +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// Reads the role directory directly from Fitz KV, independent of any projector's workload +/// scope — same direct-read pattern as . +/// +sealed class FitzRoleDirectoryReader(IKvClient client) : IRoleDirectoryReader +{ + const int DefaultLimit = 50; + const int MaxLimit = 200; + + public ValueTask GetAsync(Uuid tenantId, Uuid roleId, CancellationToken ct = default) => + RoleDirectorySchema.Directory.GetAsync(client, RoleDirectoryKeys.Route(tenantId.ToString()), roleId, ct); + + public ValueTask> ListAsync( + Uuid tenantId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) + { + var route = RoleDirectoryKeys.Route(tenantId.ToString()); + if (search is null) + { + var query = RoleDirectorySchema.ByName.Query().After(cursor); + if (limit is not null) + { + query = query.Take(limit.Value); + } + + if (descending) + { + query = query.Descending(); + } + + return RoleDirectorySchema.Directory.QueryAsync(client, route, query, ct); + } + + return SearchAsync(route, limit, cursor, search, descending, ct); + } + + // Same reasoning as FitzTeamDirectoryReader.SearchAsync — see + // design-api-contracts-hide-impl-strategy: Search means substring, not "whatever the index can + // serve directly." + async ValueTask> SearchAsync( + string route, int? limit, string? cursor, string search, bool descending, CancellationToken ct) + { + var effectiveLimit = Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + var matches = new List(); + var scanCursor = cursor; + while (matches.Count < effectiveLimit) + { + var step = RoleDirectorySchema.ByName.Query().Take(1).After(scanCursor); + if (descending) + { + step = step.Descending(); + } + + var page = await RoleDirectorySchema.Directory.QueryAsync(client, route, step, ct).ConfigureAwait(false); + if (page.Items.Count == 0) + { + scanCursor = null; + break; + } + + if (page.Items[0].Name.Contains(search, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(page.Items[0]); + } + + scanCursor = page.NextCursor; + if (scanCursor is null) + { + break; + } + } + + return new Page(matches, matches.Count == effectiveLimit ? scanCursor : null); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryProjection.cs b/src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryProjection.cs new file mode 100644 index 0000000..fd12376 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryProjection.cs @@ -0,0 +1,34 @@ +using Cntryl.Fitz; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +sealed class FitzRolePermissionDirectoryProjection(IKvClient client, WorkloadContext workload) + : FitzKvProjectionStore(client, Route(workload)), IRolePermissionDirectoryProjection +{ + public async ValueTask ApplyAsync(DomainEvent domainEvent, CancellationToken ct = default) + { + switch (domainEvent) + { + case RolePermissionAssigned assigned: + await RolePermissionDirectorySchema.Directory.InsertAsync( + Transaction, + new RolePermissionView(assigned.RoleId, assigned.Permission), + ct).ConfigureAwait(false); + break; + case RolePermissionRemoved removed: + await RolePermissionDirectorySchema.Directory.DeleteAsync( + Transaction, + (removed.RoleId, removed.Permission), + ct).ConfigureAwait(false); + break; + } + } + + static string Route(WorkloadContext workload) + { + var tenant = workload.Identity.Tenant + ?? throw new InvalidOperationException("The role-permission directory projection requires a tenant workload."); + return RolePermissionDirectoryKeys.Route(tenant.Value); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryReader.cs b/src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryReader.cs new file mode 100644 index 0000000..d580b11 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/FitzRolePermissionDirectoryReader.cs @@ -0,0 +1,85 @@ +using Cntryl.Fitz; +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// Reads the role-permission directory directly from Fitz KV, independent of any projector's +/// workload scope — same direct-read pattern as . +/// +sealed class FitzRolePermissionDirectoryReader(IKvClient client) : IRolePermissionDirectoryReader +{ + const int DefaultLimit = 50; + const int MaxLimit = 200; + + public ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) + { + var route = RolePermissionDirectoryKeys.Route(tenantId.ToString()); + if (search is null) + { + var query = RolePermissionDirectorySchema.ByRole.Query().WithPrefix(roleId.ToString()).After(cursor); + if (limit is not null) + { + query = query.Take(limit.Value); + } + + if (descending) + { + query = query.Descending(); + } + + return RolePermissionDirectorySchema.Directory.QueryAsync(client, route, query, ct); + } + + return SearchAsync(route, roleId, limit, cursor, search, descending, ct); + } + + // Same reasoning as FitzTeamMemberDirectoryReader.SearchAsync — see + // design-api-contracts-hide-impl-strategy: Search means substring, not "whatever the index can + // serve directly." + async ValueTask> SearchAsync( + string route, Uuid roleId, int? limit, string? cursor, string search, bool descending, CancellationToken ct) + { + var effectiveLimit = Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + var matches = new List(); + var scanCursor = cursor; + while (matches.Count < effectiveLimit) + { + var step = RolePermissionDirectorySchema.ByRole.Query().WithPrefix(roleId.ToString()).Take(1) + .After(scanCursor); + if (descending) + { + step = step.Descending(); + } + + var page = await RolePermissionDirectorySchema.Directory.QueryAsync(client, route, step, ct) + .ConfigureAwait(false); + if (page.Items.Count == 0) + { + scanCursor = null; + break; + } + + if (page.Items[0].Permission.Contains(search, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(page.Items[0]); + } + + scanCursor = page.NextCursor; + if (scanCursor is null) + { + break; + } + } + + return new Page(matches, matches.Count == effectiveLimit ? scanCursor : null); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryProjection.cs b/src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryProjection.cs new file mode 100644 index 0000000..36266ee --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryProjection.cs @@ -0,0 +1,34 @@ +using Cntryl.Fitz; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +sealed class FitzRoleTeamDirectoryProjection(IKvClient client, WorkloadContext workload) + : FitzKvProjectionStore(client, Route(workload)), IRoleTeamDirectoryProjection +{ + public async ValueTask ApplyAsync(DomainEvent domainEvent, CancellationToken ct = default) + { + switch (domainEvent) + { + case TeamRoleAssigned assigned: + await RoleTeamDirectorySchema.Directory.InsertAsync( + Transaction, + new RoleTeamView(assigned.RoleId, assigned.TeamId), + ct).ConfigureAwait(false); + break; + case TeamRoleRemoved removed: + await RoleTeamDirectorySchema.Directory.DeleteAsync( + Transaction, + (removed.RoleId, removed.TeamId), + ct).ConfigureAwait(false); + break; + } + } + + static string Route(WorkloadContext workload) + { + var tenant = workload.Identity.Tenant + ?? throw new InvalidOperationException("The role-team directory projection requires a tenant workload."); + return RoleTeamDirectoryKeys.Route(tenant.Value); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryReader.cs b/src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryReader.cs new file mode 100644 index 0000000..3851397 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/FitzRoleTeamDirectoryReader.cs @@ -0,0 +1,85 @@ +using Cntryl.Fitz; +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// Reads the role-team directory directly from Fitz KV, independent of any projector's workload +/// scope — same direct-read pattern as . +/// +sealed class FitzRoleTeamDirectoryReader(IKvClient client) : IRoleTeamDirectoryReader +{ + const int DefaultLimit = 50; + const int MaxLimit = 200; + + public ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) + { + var route = RoleTeamDirectoryKeys.Route(tenantId.ToString()); + if (search is null) + { + var query = RoleTeamDirectorySchema.ByRole.Query().WithPrefix(roleId.ToString()).After(cursor); + if (limit is not null) + { + query = query.Take(limit.Value); + } + + if (descending) + { + query = query.Descending(); + } + + return RoleTeamDirectorySchema.Directory.QueryAsync(client, route, query, ct); + } + + return SearchAsync(route, roleId, limit, cursor, search, descending, ct); + } + + // Same reasoning as FitzTeamMemberDirectoryReader.SearchAsync — see + // design-api-contracts-hide-impl-strategy: Search means substring, not "whatever the index can + // serve directly." + async ValueTask> SearchAsync( + string route, Uuid roleId, int? limit, string? cursor, string search, bool descending, CancellationToken ct) + { + var effectiveLimit = Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + var matches = new List(); + var scanCursor = cursor; + while (matches.Count < effectiveLimit) + { + var step = RoleTeamDirectorySchema.ByRole.Query().WithPrefix(roleId.ToString()).Take(1) + .After(scanCursor); + if (descending) + { + step = step.Descending(); + } + + var page = await RoleTeamDirectorySchema.Directory.QueryAsync(client, route, step, ct) + .ConfigureAwait(false); + if (page.Items.Count == 0) + { + scanCursor = null; + break; + } + + if (page.Items[0].TeamId.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(page.Items[0]); + } + + scanCursor = page.NextCursor; + if (scanCursor is null) + { + break; + } + } + + return new Page(matches, matches.Count == effectiveLimit ? scanCursor : null); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/GetRoleHandler.cs b/src/Compliance.Core/Features/AccessControl/GetRoleHandler.cs new file mode 100644 index 0000000..77c0968 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/GetRoleHandler.cs @@ -0,0 +1,14 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed class GetRoleHandler(IRoleDirectoryReader directory) : IRequestHandler +{ + public async ValueTask> HandleAsync(IRequestContext context, CancellationToken ct) + { + var role = await directory.GetAsync(context.Request.TenantId, context.Request.RoleId, ct); + return role is null + ? Result.Failure(new RequestError(RequestErrorKind.NotFound, "The role was not found.")) + : Result.Success(role); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/IRoleDirectoryProjection.cs b/src/Compliance.Core/Features/AccessControl/IRoleDirectoryProjection.cs new file mode 100644 index 0000000..6b38db0 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/IRoleDirectoryProjection.cs @@ -0,0 +1,9 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Materializes role-directory events into the queryable role directory. +public interface IRoleDirectoryProjection : IProjectionStore +{ + ValueTask ApplyAsync(DomainEvent domainEvent, CancellationToken ct = default); +} diff --git a/src/Compliance.Core/Features/AccessControl/IRoleDirectoryReader.cs b/src/Compliance.Core/Features/AccessControl/IRoleDirectoryReader.cs new file mode 100644 index 0000000..696c61f --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/IRoleDirectoryReader.cs @@ -0,0 +1,19 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Reads the materialized role directory for a tenant. +public interface IRoleDirectoryReader +{ + ValueTask GetAsync(Uuid tenantId, Uuid roleId, CancellationToken ct = default); + + /// Lists a tenant's roles by name. + /// A case-insensitive name substring filter, or for none. + ValueTask> ListAsync( + Uuid tenantId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default); +} diff --git a/src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryProjection.cs b/src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryProjection.cs new file mode 100644 index 0000000..bd90307 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryProjection.cs @@ -0,0 +1,9 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Materializes role-permission events into the queryable role-permission directory. +public interface IRolePermissionDirectoryProjection : IProjectionStore +{ + ValueTask ApplyAsync(DomainEvent domainEvent, CancellationToken ct = default); +} diff --git a/src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryReader.cs b/src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryReader.cs new file mode 100644 index 0000000..fc1c5a5 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/IRolePermissionDirectoryReader.cs @@ -0,0 +1,18 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Reads the materialized role-permission directory for one role. +public interface IRolePermissionDirectoryReader +{ + /// Lists one role's permissions. + /// A permission-substring filter, or for none. + ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default); +} diff --git a/src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryProjection.cs b/src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryProjection.cs new file mode 100644 index 0000000..acbb79b --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryProjection.cs @@ -0,0 +1,9 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Materializes team-role events into the queryable role-team directory. +public interface IRoleTeamDirectoryProjection : IProjectionStore +{ + ValueTask ApplyAsync(DomainEvent domainEvent, CancellationToken ct = default); +} diff --git a/src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryReader.cs b/src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryReader.cs new file mode 100644 index 0000000..85cc1b1 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/IRoleTeamDirectoryReader.cs @@ -0,0 +1,18 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// Reads the materialized role-team directory for one role. +public interface IRoleTeamDirectoryReader +{ + /// Lists the teams holding one role. + /// A team-ID substring filter, or for none. + ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default); +} diff --git a/src/Compliance.Core/Features/AccessControl/ListRolePermissionsHandler.cs b/src/Compliance.Core/Features/AccessControl/ListRolePermissionsHandler.cs new file mode 100644 index 0000000..520ad6b --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/ListRolePermissionsHandler.cs @@ -0,0 +1,22 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed class ListRolePermissionsHandler(IRolePermissionDirectoryReader directory) + : IRequestHandler> +{ + public async ValueTask>> HandleAsync( + IRequestContext context, CancellationToken ct) + { + var request = context.Request; + var page = await directory.ListAsync( + request.TenantId, + request.RoleId, + request.Limit, + request.Cursor, + ListRequestNormalization.NormalizeSearch(request.Search), + ListRequestNormalization.IsDescending(request.Sort), + ct); + return Result>.Success(page); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/ListRoleTeamsHandler.cs b/src/Compliance.Core/Features/AccessControl/ListRoleTeamsHandler.cs new file mode 100644 index 0000000..9d30793 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/ListRoleTeamsHandler.cs @@ -0,0 +1,22 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed class ListRoleTeamsHandler(IRoleTeamDirectoryReader directory) + : IRequestHandler> +{ + public async ValueTask>> HandleAsync( + IRequestContext context, CancellationToken ct) + { + var request = context.Request; + var page = await directory.ListAsync( + request.TenantId, + request.RoleId, + request.Limit, + request.Cursor, + ListRequestNormalization.NormalizeSearch(request.Search), + ListRequestNormalization.IsDescending(request.Sort), + ct); + return Result>.Success(page); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/ListRolesHandler.cs b/src/Compliance.Core/Features/AccessControl/ListRolesHandler.cs new file mode 100644 index 0000000..a635449 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/ListRolesHandler.cs @@ -0,0 +1,19 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed class ListRolesHandler(IRoleDirectoryReader directory) : IRequestHandler> +{ + public async ValueTask>> HandleAsync(IRequestContext context, CancellationToken ct) + { + var request = context.Request; + var page = await directory.ListAsync( + request.TenantId, + request.Limit, + request.Cursor, + ListRequestNormalization.NormalizeSearch(request.Search), + ListRequestNormalization.IsDescending(request.Sort), + ct); + return Result>.Success(page); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/PermissionProjectionState.cs b/src/Compliance.Core/Features/AccessControl/PermissionProjectionState.cs index 4ee7d85..62ac7ae 100644 --- a/src/Compliance.Core/Features/AccessControl/PermissionProjectionState.cs +++ b/src/Compliance.Core/Features/AccessControl/PermissionProjectionState.cs @@ -39,9 +39,15 @@ public void Apply(DomainEvent domainEvent) case RolePermissionAssigned rolePermission: RolePermissions.Add(new RolePermissionEdge(rolePermission.RoleId, rolePermission.Permission)); break; + case RolePermissionRemoved rolePermission: + RolePermissions.Remove(new RolePermissionEdge(rolePermission.RoleId, rolePermission.Permission)); + break; case TeamRoleAssigned teamRole: TeamRoles.Add(new TeamRoleEdge(teamRole.TeamId, teamRole.RoleId)); break; + case TeamRoleRemoved teamRole: + TeamRoles.Remove(new TeamRoleEdge(teamRole.TeamId, teamRole.RoleId)); + break; } } diff --git a/src/Compliance.Core/Features/AccessControl/PermissionProjector.cs b/src/Compliance.Core/Features/AccessControl/PermissionProjector.cs index 10e56e9..f09ab50 100644 --- a/src/Compliance.Core/Features/AccessControl/PermissionProjector.cs +++ b/src/Compliance.Core/Features/AccessControl/PermissionProjector.cs @@ -12,7 +12,9 @@ public sealed partial class PermissionProjector(IPermissionProjection projection IProjectorHandler, IProjectorHandler, IProjectorHandler, - IProjectorHandler + IProjectorHandler, + IProjectorHandler, + IProjectorHandler { public ValueTask HandleAsync(MemberRegistered ev, IProjectorContext context, CancellationToken ct) => projection.ApplyAsync(ev, ct); @@ -38,6 +40,12 @@ public ValueTask HandleAsync(RoleDeleted ev, IProjectorContext context, Cancella public ValueTask HandleAsync(RolePermissionAssigned ev, IProjectorContext context, CancellationToken ct) => projection.ApplyAsync(ev, ct); + public ValueTask HandleAsync(RolePermissionRemoved ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); + public ValueTask HandleAsync(TeamRoleAssigned ev, IProjectorContext context, CancellationToken ct) => projection.ApplyAsync(ev, ct); + + public ValueTask HandleAsync(TeamRoleRemoved ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); } diff --git a/src/Compliance.Core/Features/AccessControl/RemoveRolePermissionHandler.cs b/src/Compliance.Core/Features/AccessControl/RemoveRolePermissionHandler.cs new file mode 100644 index 0000000..059f8e3 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RemoveRolePermissionHandler.cs @@ -0,0 +1,13 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed class RemoveRolePermissionHandler(IAggregateExecutor executor) + : IRequestHandler +{ + public ValueTask HandleAsync(IRequestContext context, CancellationToken ct) => + executor.ExecuteAsync( + new RolePermission(context.Request.TenantId, context.Request.RoleId, context.Request.Permission), + assignment => AggregateOutcome.CommitOnSuccess(assignment.Remove()), + context, ct); +} diff --git a/src/Compliance.Core/Features/AccessControl/RemoveTeamRoleHandler.cs b/src/Compliance.Core/Features/AccessControl/RemoveTeamRoleHandler.cs new file mode 100644 index 0000000..47badbf --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RemoveTeamRoleHandler.cs @@ -0,0 +1,12 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed class RemoveTeamRoleHandler(IAggregateExecutor executor) : IRequestHandler +{ + public ValueTask HandleAsync(IRequestContext context, CancellationToken ct) => + executor.ExecuteAsync( + new TeamRole(context.Request.TenantId, context.Request.TeamId, context.Request.RoleId), + assignment => AggregateOutcome.CommitOnSuccess(assignment.Remove()), + context, ct); +} diff --git a/src/Compliance.Core/Features/AccessControl/RoleCleanupReactor.cs b/src/Compliance.Core/Features/AccessControl/RoleCleanupReactor.cs new file mode 100644 index 0000000..6400167 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RoleCleanupReactor.cs @@ -0,0 +1,52 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// Removes a deleted role's permission assignments and team assignments so no orphaned +/// / assignment survives the role itself. +/// Not authoritative on its own — see for the same reasoning. +/// +public sealed partial class RoleCleanupReactor( + IProjectionCheckpointStore checkpoints, + IRequestBus bus, + IRolePermissionDirectoryReader permissions, + IRoleTeamDirectoryReader teams) + : Reactor(checkpoints, EventStreamPattern.ForTenant("rbac-roles"), "RoleCleanup"), + IReactorHandler +{ + public async ValueTask HandleAsync(IReactorContext context, CancellationToken ct) + { + var tenantId = context.Trigger.TenantId; + var roleId = context.Trigger.RoleId; + + string? permissionCursor = null; + do + { + var page = await permissions.ListAsync(tenantId, roleId, 200, permissionCursor, null, descending: false, ct) + .ConfigureAwait(false); + foreach (var permission in page.Items) + { + await bus.SendReactionAsync( + new RemoveRolePermission(tenantId, roleId, permission.Permission), context, ct) + .ConfigureAwait(false); + } + + permissionCursor = page.NextCursor; + } while (permissionCursor is not null); + + string? teamCursor = null; + do + { + var page = await teams.ListAsync(tenantId, roleId, 200, teamCursor, null, descending: false, ct) + .ConfigureAwait(false); + foreach (var team in page.Items) + { + await bus.SendReactionAsync(new RemoveTeamRole(tenantId, team.TeamId, roleId), context, ct) + .ConfigureAwait(false); + } + + teamCursor = page.NextCursor; + } while (teamCursor is not null); + } +} diff --git a/src/Compliance.Core/Features/AccessControl/RoleDirectoryKeys.cs b/src/Compliance.Core/Features/AccessControl/RoleDirectoryKeys.cs new file mode 100644 index 0000000..2304cb7 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RoleDirectoryKeys.cs @@ -0,0 +1,6 @@ +namespace Bdgrz.Compliance.Features.AccessControl; + +static class RoleDirectoryKeys +{ + public static string Route(string tenantId) => $"kv://bdgrz/role-directory/{tenantId}"; +} diff --git a/src/Compliance.Core/Features/AccessControl/RoleDirectoryProjector.cs b/src/Compliance.Core/Features/AccessControl/RoleDirectoryProjector.cs new file mode 100644 index 0000000..7e15d13 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RoleDirectoryProjector.cs @@ -0,0 +1,15 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed partial class RoleDirectoryProjector(IRoleDirectoryProjection projection) + : Projector(projection, EventStreamPattern.ForTenant(), "RoleDirectory"), + IProjectorHandler, + IProjectorHandler +{ + public ValueTask HandleAsync(RoleDefined ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); + + public ValueTask HandleAsync(RoleDeleted ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); +} diff --git a/src/Compliance.Core/Features/AccessControl/RoleDirectorySchema.cs b/src/Compliance.Core/Features/AccessControl/RoleDirectorySchema.cs new file mode 100644 index 0000000..4de7d99 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RoleDirectorySchema.cs @@ -0,0 +1,20 @@ +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// The shared role-directory schema, used by both the read and write sides. +static class RoleDirectorySchema +{ + public static readonly KvDirectoryIndex ByName = new( + "by_name", 1, static role => [Normalize(role.Name)]); + + public static readonly KvDirectory Directory = new( + "roles", + ComplianceCoreJsonContext.Default.RoleView, + static role => role.RoleId, + static roleId => [roleId.ToString()], + [ByName]); + + public static string Normalize(string value) => value.ToUpperInvariant(); +} diff --git a/src/Compliance.Core/Features/AccessControl/RolePermission.cs b/src/Compliance.Core/Features/AccessControl/RolePermission.cs index bd81089..3ca5777 100644 --- a/src/Compliance.Core/Features/AccessControl/RolePermission.cs +++ b/src/Compliance.Core/Features/AccessControl/RolePermission.cs @@ -24,6 +24,7 @@ public RolePermission(Uuid tenantId, Uuid roleId, string permission) _roleId = roleId; _permission = Permissions.Normalize(permission); On(_ => _isAssigned = true); + On(_ => _isAssigned = false); } public Result Assign() @@ -32,4 +33,12 @@ public Result Assign() RaiseEvent(new RolePermissionAssigned(_tenantId, _roleId, _permission)); return Result.Success; } + + public Result Remove() + { + if (!_isAssigned) + return Result.Failure(new RequestError(RequestErrorKind.NotFound, "The permission is not assigned to this role.")); + RaiseEvent(new RolePermissionRemoved(_tenantId, _roleId, _permission)); + return Result.Success; + } } diff --git a/src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryKeys.cs b/src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryKeys.cs new file mode 100644 index 0000000..5ff9b4d --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryKeys.cs @@ -0,0 +1,6 @@ +namespace Bdgrz.Compliance.Features.AccessControl; + +static class RolePermissionDirectoryKeys +{ + public static string Route(string tenantId) => $"kv://bdgrz/role-permission-directory/{tenantId}"; +} diff --git a/src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryProjector.cs b/src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryProjector.cs new file mode 100644 index 0000000..67d0888 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RolePermissionDirectoryProjector.cs @@ -0,0 +1,15 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed partial class RolePermissionDirectoryProjector(IRolePermissionDirectoryProjection projection) + : Projector(projection, EventStreamPattern.ForTenant(), "RolePermissionDirectory"), + IProjectorHandler, + IProjectorHandler +{ + public ValueTask HandleAsync(RolePermissionAssigned ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); + + public ValueTask HandleAsync(RolePermissionRemoved ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); +} diff --git a/src/Compliance.Core/Features/AccessControl/RolePermissionDirectorySchema.cs b/src/Compliance.Core/Features/AccessControl/RolePermissionDirectorySchema.cs new file mode 100644 index 0000000..559f51a --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RolePermissionDirectorySchema.cs @@ -0,0 +1,18 @@ +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// The shared role-permission-directory schema, used by both the read and write sides. +static class RolePermissionDirectorySchema +{ + public static readonly KvDirectoryIndex ByRole = new( + "by_role", 1, static view => [view.RoleId.ToString(), view.Permission]); + + public static readonly KvDirectory Directory = new( + "role-permissions", + ComplianceCoreJsonContext.Default.RolePermissionView, + static view => (view.RoleId, view.Permission), + static identity => [identity.RoleId.ToString(), identity.Permission], + [ByRole]); +} diff --git a/src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryKeys.cs b/src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryKeys.cs new file mode 100644 index 0000000..5f2fd1e --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryKeys.cs @@ -0,0 +1,6 @@ +namespace Bdgrz.Compliance.Features.AccessControl; + +static class RoleTeamDirectoryKeys +{ + public static string Route(string tenantId) => $"kv://bdgrz/role-team-directory/{tenantId}"; +} diff --git a/src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryProjector.cs b/src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryProjector.cs new file mode 100644 index 0000000..7fa6526 --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RoleTeamDirectoryProjector.cs @@ -0,0 +1,15 @@ +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +public sealed partial class RoleTeamDirectoryProjector(IRoleTeamDirectoryProjection projection) + : Projector(projection, EventStreamPattern.ForTenant(), "RoleTeamDirectory"), + IProjectorHandler, + IProjectorHandler +{ + public ValueTask HandleAsync(TeamRoleAssigned ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); + + public ValueTask HandleAsync(TeamRoleRemoved ev, IProjectorContext context, CancellationToken ct) => + projection.ApplyAsync(ev, ct); +} diff --git a/src/Compliance.Core/Features/AccessControl/RoleTeamDirectorySchema.cs b/src/Compliance.Core/Features/AccessControl/RoleTeamDirectorySchema.cs new file mode 100644 index 0000000..72e2a7d --- /dev/null +++ b/src/Compliance.Core/Features/AccessControl/RoleTeamDirectorySchema.cs @@ -0,0 +1,23 @@ +using Cntryl.Fitz.Extensions; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Features.AccessControl; + +/// +/// The shared role-team-directory schema — the same / +/// events as rbac-team-roles, materialized keyed by role +/// first instead of by team, so a role's detail page can list the teams holding it without a +/// substring-search fallback over an unrelated index. +/// +static class RoleTeamDirectorySchema +{ + public static readonly KvDirectoryIndex ByRole = new( + "by_role", 1, static view => [view.RoleId.ToString(), view.TeamId.ToString()]); + + public static readonly KvDirectory Directory = new( + "role-teams", + ComplianceCoreJsonContext.Default.RoleTeamView, + static view => (view.RoleId, view.TeamId), + static identity => [identity.RoleId.ToString(), identity.TeamId.ToString()], + [ByRole]); +} diff --git a/src/Compliance.Core/Features/AccessControl/TeamRole.cs b/src/Compliance.Core/Features/AccessControl/TeamRole.cs index 608fa43..de2e38d 100644 --- a/src/Compliance.Core/Features/AccessControl/TeamRole.cs +++ b/src/Compliance.Core/Features/AccessControl/TeamRole.cs @@ -21,6 +21,7 @@ public TeamRole(Uuid tenantId, Uuid teamId, Uuid roleId) _teamId = teamId; _roleId = roleId; On(_ => _isAssigned = true); + On(_ => _isAssigned = false); } public Result Assign() @@ -29,4 +30,12 @@ public Result Assign() RaiseEvent(new TeamRoleAssigned(_tenantId, _teamId, _roleId)); return Result.Success; } + + public Result Remove() + { + if (!_isAssigned) + return Result.Failure(new RequestError(RequestErrorKind.NotFound, "The role is not assigned to this team.")); + RaiseEvent(new TeamRoleRemoved(_tenantId, _teamId, _roleId)); + return Result.Success; + } } diff --git a/test/Compliance.Tests/Features/AccessControl/DeleteRoleRequestScenarioTests.cs b/test/Compliance.Tests/Features/AccessControl/DeleteRoleRequestScenarioTests.cs new file mode 100644 index 0000000..7e36857 --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/DeleteRoleRequestScenarioTests.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using Cntryl.Portia; +using Cntryl.Portia.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class DeleteRoleRequestScenarioTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + + [Fact] + public async Task ShouldDeleteADefinedRoleGivenTenantRbacManagePermission() + { + await using var provider = BuildProvider(allowed: true); + await Seed(provider); + + await RequestScenario.For(provider) + .GivenActor(BdgrzActor()) + .When(new DeleteRole(TenantId, RoleId)) + .ExpectAuthorized() + .ExpectHandled() + .ExpectSuccess(); + } + + [Fact] + public async Task ShouldDenyGivenAnActorWithoutTheTenantRbacManagePermission() + { + await using var provider = BuildProvider(allowed: false); + await Seed(provider); + + await RequestScenario.For(provider) + .GivenActor(BdgrzActor()) + .When(new DeleteRole(TenantId, RoleId)) + .ExpectDenied(RequestErrorKind.Forbidden) + .ExpectNotHandled(); + } + + static async Task Seed(ServiceProvider provider) + { + await using var scope = provider.CreateAsyncScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + await executor.ExecuteAsync( + new Role(TenantId, RoleId), + role => AggregateOutcome.Commit(role.Define("Reviewer")), + new RequestDispatchContext(RequestActor.System), + CancellationToken.None); + } + + static ServiceProvider BuildProvider(bool allowed) + { + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryEventStore()); + services.AddSingleton(new FakePermissionAuthorizer(allowed)); + services.AddPortia() + .AddRequestHandler() + .AddRequestAuthorizer(); + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + } + + static ClaimsPrincipal BdgrzActor() => new(new ClaimsIdentity( + [new Claim("iss", "bdgrz"), new Claim("sub", Uuid.CreateVersion4().ToString())], "BdgrzSession")); + + sealed class FakePermissionAuthorizer(bool allowed) : IPermissionAuthorizer + { + public ValueTask IsAllowedAsync( + Uuid tenantId, + Uuid memberId, + string permission, + CancellationToken ct = default) => ValueTask.FromResult(allowed); + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/FitzRoleDirectoryReaderTests.cs b/test/Compliance.Tests/Features/AccessControl/FitzRoleDirectoryReaderTests.cs new file mode 100644 index 0000000..295ba72 --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/FitzRoleDirectoryReaderTests.cs @@ -0,0 +1,89 @@ +using Cntryl.Fitz; +using Cntryl.Fitz.Testing; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +/// +/// Exercises the real KvDirectory-backed index/cursor/search logic against Fitz's own in-memory +/// KV double — same coverage as . +/// +public sealed class FitzRoleDirectoryReaderTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + + [Fact] + public async Task GetAsyncShouldReturnTheStoredRole() + { + var client = new InMemoryKvClient(); + var roleId = Uuid.CreateVersion4(); + await SeedAsync(client, roleId, "Reviewer"); + var reader = new FitzRoleDirectoryReader(client); + + var role = await reader.GetAsync(TenantId, roleId, CancellationToken.None); + + Assert.NotNull(role); + Assert.Equal("Reviewer", role.Name); + } + + [Fact] + public async Task GetAsyncShouldReturnNullGivenNoSuchRole() + { + var reader = new FitzRoleDirectoryReader(new InMemoryKvClient()); + + var role = await reader.GetAsync(TenantId, Uuid.CreateVersion4(), CancellationToken.None); + + Assert.Null(role); + } + + [Fact] + public async Task ListAsyncShouldReturnEveryRoleInNameOrder() + { + var client = new InMemoryKvClient(); + await SeedAsync(client, Uuid.CreateVersion4(), "Beta"); + await SeedAsync(client, Uuid.CreateVersion4(), "Alpha"); + var reader = new FitzRoleDirectoryReader(client); + + var page = await reader.ListAsync(TenantId, null, null, null, descending: false, CancellationToken.None); + + Assert.Equal(["Alpha", "Beta"], page.Items.Select(role => role.Name)); + Assert.Null(page.NextCursor); + } + + [Fact] + public async Task ListAsyncShouldFilterByNameSubstring() + { + var client = new InMemoryKvClient(); + await SeedAsync(client, Uuid.CreateVersion4(), "Site Reviewer"); + await SeedAsync(client, Uuid.CreateVersion4(), "Administrator"); + var reader = new FitzRoleDirectoryReader(client); + + var page = await reader.ListAsync(TenantId, null, null, "review", descending: false, CancellationToken.None); + + var role = Assert.Single(page.Items); + Assert.Equal("Site Reviewer", role.Name); + } + + [Fact] + public async Task ListAsyncShouldOnlyReturnRolesForTheRequestedTenant() + { + var client = new InMemoryKvClient(); + var otherTenantId = Uuid.CreateVersion4(); + await SeedAsync(client, Uuid.CreateVersion4(), "Mine", TenantId); + await SeedAsync(client, Uuid.CreateVersion4(), "Theirs", otherTenantId); + var reader = new FitzRoleDirectoryReader(client); + + var page = await reader.ListAsync(TenantId, null, null, null, descending: false, CancellationToken.None); + + var role = Assert.Single(page.Items); + Assert.Equal("Mine", role.Name); + } + + static async Task SeedAsync(InMemoryKvClient client, Uuid roleId, string name, Uuid? tenantId = null) + { + await using var transaction = await client.BeginAsync( + RoleDirectoryKeys.Route((tenantId ?? TenantId).ToString()), KvDurability.Async, KvMode.ReadWrite); + await RoleDirectorySchema.Directory.InsertAsync(transaction, new RoleView(roleId, name)); + await transaction.CommitAsync(); + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/FitzRolePermissionDirectoryReaderTests.cs b/test/Compliance.Tests/Features/AccessControl/FitzRolePermissionDirectoryReaderTests.cs new file mode 100644 index 0000000..8be19de --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/FitzRolePermissionDirectoryReaderTests.cs @@ -0,0 +1,66 @@ +using Cntryl.Fitz; +using Cntryl.Fitz.Testing; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class FitzRolePermissionDirectoryReaderTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + + [Fact] + public async Task ListAsyncShouldReturnEveryPermissionOfTheRole() + { + var client = new InMemoryKvClient(); + await SeedAsync(client, RoleId, "controls.read"); + await SeedAsync(client, RoleId, "controls.manage"); + var reader = new FitzRolePermissionDirectoryReader(client); + + var page = await reader.ListAsync( + TenantId, RoleId, null, null, null, descending: false, CancellationToken.None); + + Assert.Equal(2, page.Items.Count); + Assert.Null(page.NextCursor); + } + + [Fact] + public async Task ListAsyncShouldOnlyReturnPermissionsOfTheRequestedRole() + { + var client = new InMemoryKvClient(); + var otherRoleId = Uuid.CreateVersion4(); + await SeedAsync(client, RoleId, "controls.read"); + await SeedAsync(client, otherRoleId, "controls.manage"); + var reader = new FitzRolePermissionDirectoryReader(client); + + var page = await reader.ListAsync( + TenantId, RoleId, null, null, null, descending: false, CancellationToken.None); + + var result = Assert.Single(page.Items); + Assert.Equal("controls.read", result.Permission); + } + + [Fact] + public async Task ListAsyncShouldFilterByPermissionSubstring() + { + var client = new InMemoryKvClient(); + await SeedAsync(client, RoleId, "controls.read"); + await SeedAsync(client, RoleId, "tenant.access"); + var reader = new FitzRolePermissionDirectoryReader(client); + + var page = await reader.ListAsync( + TenantId, RoleId, null, null, "trols", descending: false, CancellationToken.None); + + var result = Assert.Single(page.Items); + Assert.Equal("controls.read", result.Permission); + } + + static async Task SeedAsync(InMemoryKvClient client, Uuid roleId, string permission) + { + await using var transaction = await client.BeginAsync( + RolePermissionDirectoryKeys.Route(TenantId.ToString()), KvDurability.Async, KvMode.ReadWrite); + await RolePermissionDirectorySchema.Directory.InsertAsync( + transaction, new RolePermissionView(roleId, permission)); + await transaction.CommitAsync(); + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/FitzRoleTeamDirectoryReaderTests.cs b/test/Compliance.Tests/Features/AccessControl/FitzRoleTeamDirectoryReaderTests.cs new file mode 100644 index 0000000..9ecf515 --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/FitzRoleTeamDirectoryReaderTests.cs @@ -0,0 +1,54 @@ +using Cntryl.Fitz; +using Cntryl.Fitz.Testing; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class FitzRoleTeamDirectoryReaderTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + + [Fact] + public async Task ListAsyncShouldReturnEveryTeamHoldingTheRole() + { + var client = new InMemoryKvClient(); + var first = Uuid.CreateVersion4(); + var second = Uuid.CreateVersion4(); + await SeedAsync(client, RoleId, first); + await SeedAsync(client, RoleId, second); + var reader = new FitzRoleTeamDirectoryReader(client); + + var page = await reader.ListAsync( + TenantId, RoleId, null, null, null, descending: false, CancellationToken.None); + + Assert.Equal(2, page.Items.Count); + Assert.Null(page.NextCursor); + } + + [Fact] + public async Task ListAsyncShouldOnlyReturnTeamsForTheRequestedRole() + { + var client = new InMemoryKvClient(); + var otherRoleId = Uuid.CreateVersion4(); + var team = Uuid.CreateVersion4(); + var otherTeam = Uuid.CreateVersion4(); + await SeedAsync(client, RoleId, team); + await SeedAsync(client, otherRoleId, otherTeam); + var reader = new FitzRoleTeamDirectoryReader(client); + + var page = await reader.ListAsync( + TenantId, RoleId, null, null, null, descending: false, CancellationToken.None); + + var result = Assert.Single(page.Items); + Assert.Equal(team, result.TeamId); + } + + static async Task SeedAsync(InMemoryKvClient client, Uuid roleId, Uuid teamId) + { + await using var transaction = await client.BeginAsync( + RoleTeamDirectoryKeys.Route(TenantId.ToString()), KvDurability.Async, KvMode.ReadWrite); + await RoleTeamDirectorySchema.Directory.InsertAsync(transaction, new RoleTeamView(roleId, teamId)); + await transaction.CommitAsync(); + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/ListRolePermissionsHandlerTests.cs b/test/Compliance.Tests/Features/AccessControl/ListRolePermissionsHandlerTests.cs new file mode 100644 index 0000000..7f1cafb --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/ListRolePermissionsHandlerTests.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class ListRolePermissionsHandlerTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + + [Fact] + public async Task ShouldReturnThePageFromTheReader() + { + var reader = new FakeRolePermissionDirectoryReader(); + reader.Permissions[(TenantId, RoleId)] = [new RolePermissionView(RoleId, "controls.read")]; + var handler = new ListRolePermissionsHandler(reader); + var context = new RequestContext(new ListRolePermissions(TenantId, RoleId), Actor()); + + var result = await handler.HandleAsync(context, CancellationToken.None); + + Assert.True(result.IsSuccess); + var permission = Assert.Single(result.Value.Items); + Assert.Equal("controls.read", permission.Permission); + } + + [Fact] + public async Task ShouldBuildANormalizedQueryFromTheRequest() + { + var reader = new FakeRolePermissionDirectoryReader(); + var handler = new ListRolePermissionsHandler(reader); + var context = new RequestContext( + new ListRolePermissions(TenantId, RoleId, Limit: 5, Cursor: "opaque", Search: " abc ", Sort: "permission:desc"), + Actor()); + + _ = await handler.HandleAsync(context, CancellationToken.None); + + Assert.Equal(5, reader.LastLimit); + Assert.Equal("opaque", reader.LastCursor); + Assert.Equal("abc", reader.LastSearch); + Assert.True(reader.LastDescending); + Assert.Equal(RoleId, reader.LastRoleId); + } + + static ClaimsPrincipal Actor() => new(new ClaimsIdentity( + [new Claim("iss", "bdgrz"), new Claim("sub", Uuid.CreateVersion4().ToString())], "BdgrzSession")); + + sealed class FakeRolePermissionDirectoryReader : IRolePermissionDirectoryReader + { + public Dictionary<(Uuid TenantId, Uuid RoleId), List> Permissions { get; } = []; + public int? LastLimit { get; private set; } + public string? LastCursor { get; private set; } + public string? LastSearch { get; private set; } + public bool LastDescending { get; private set; } + public Uuid LastRoleId { get; private set; } + + public ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) + { + LastLimit = limit; + LastCursor = cursor; + LastSearch = search; + LastDescending = descending; + LastRoleId = roleId; + IReadOnlyList items = Permissions.TryGetValue((tenantId, roleId), out var items0) + ? [.. items0.Take(limit ?? 50)] + : []; + return ValueTask.FromResult(new Page(items, null)); + } + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/ListRoleTeamsHandlerTests.cs b/test/Compliance.Tests/Features/AccessControl/ListRoleTeamsHandlerTests.cs new file mode 100644 index 0000000..2e1652a --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/ListRoleTeamsHandlerTests.cs @@ -0,0 +1,77 @@ +using System.Security.Claims; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class ListRoleTeamsHandlerTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + static readonly Uuid TeamId = Uuid.CreateVersion4(); + + [Fact] + public async Task ShouldReturnThePageFromTheReader() + { + var reader = new FakeRoleTeamDirectoryReader(); + reader.Teams[(TenantId, RoleId)] = [new RoleTeamView(RoleId, TeamId)]; + var handler = new ListRoleTeamsHandler(reader); + var context = new RequestContext(new ListRoleTeams(TenantId, RoleId), Actor()); + + var result = await handler.HandleAsync(context, CancellationToken.None); + + Assert.True(result.IsSuccess); + var team = Assert.Single(result.Value.Items); + Assert.Equal(TeamId, team.TeamId); + } + + [Fact] + public async Task ShouldBuildANormalizedQueryFromTheRequest() + { + var reader = new FakeRoleTeamDirectoryReader(); + var handler = new ListRoleTeamsHandler(reader); + var context = new RequestContext( + new ListRoleTeams(TenantId, RoleId, Limit: 5, Cursor: "opaque", Search: " abc ", Sort: "team_id:desc"), + Actor()); + + _ = await handler.HandleAsync(context, CancellationToken.None); + + Assert.Equal(5, reader.LastLimit); + Assert.Equal("opaque", reader.LastCursor); + Assert.Equal("abc", reader.LastSearch); + Assert.True(reader.LastDescending); + Assert.Equal(RoleId, reader.LastRoleId); + } + + static ClaimsPrincipal Actor() => new(new ClaimsIdentity( + [new Claim("iss", "bdgrz"), new Claim("sub", Uuid.CreateVersion4().ToString())], "BdgrzSession")); + + sealed class FakeRoleTeamDirectoryReader : IRoleTeamDirectoryReader + { + public Dictionary<(Uuid TenantId, Uuid RoleId), List> Teams { get; } = []; + public int? LastLimit { get; private set; } + public string? LastCursor { get; private set; } + public string? LastSearch { get; private set; } + public bool LastDescending { get; private set; } + public Uuid LastRoleId { get; private set; } + + public ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) + { + LastLimit = limit; + LastCursor = cursor; + LastSearch = search; + LastDescending = descending; + LastRoleId = roleId; + IReadOnlyList items = Teams.TryGetValue((tenantId, roleId), out var items0) + ? [.. items0.Take(limit ?? 50)] + : []; + return ValueTask.FromResult(new Page(items, null)); + } + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/RemoveRolePermissionRequestScenarioTests.cs b/test/Compliance.Tests/Features/AccessControl/RemoveRolePermissionRequestScenarioTests.cs new file mode 100644 index 0000000..fd8de21 --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/RemoveRolePermissionRequestScenarioTests.cs @@ -0,0 +1,74 @@ +using System.Security.Claims; +using Cntryl.Portia; +using Cntryl.Portia.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class RemoveRolePermissionRequestScenarioTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + const string Permission = "controls.read"; + + [Fact] + public async Task ShouldRemoveAnAssignedPermissionGivenTenantRbacManagePermission() + { + await using var provider = BuildProvider(allowed: true); + await Seed(provider); + + await RequestScenario.For(provider) + .GivenActor(BdgrzActor()) + .When(new RemoveRolePermission(TenantId, RoleId, Permission)) + .ExpectAuthorized() + .ExpectHandled() + .ExpectSuccess(); + } + + [Fact] + public async Task ShouldDenyGivenAnActorWithoutTheTenantRbacManagePermission() + { + await using var provider = BuildProvider(allowed: false); + await Seed(provider); + + await RequestScenario.For(provider) + .GivenActor(BdgrzActor()) + .When(new RemoveRolePermission(TenantId, RoleId, Permission)) + .ExpectDenied(RequestErrorKind.Forbidden) + .ExpectNotHandled(); + } + + static async Task Seed(ServiceProvider provider) + { + await using var scope = provider.CreateAsyncScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + await executor.ExecuteAsync( + new RolePermission(TenantId, RoleId, Permission), + rolePermission => AggregateOutcome.Commit(rolePermission.Assign()), + new RequestDispatchContext(RequestActor.System), + CancellationToken.None); + } + + static ServiceProvider BuildProvider(bool allowed) + { + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryEventStore()); + services.AddSingleton(new FakePermissionAuthorizer(allowed)); + services.AddPortia() + .AddRequestHandler() + .AddRequestAuthorizer(); + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + } + + static ClaimsPrincipal BdgrzActor() => new(new ClaimsIdentity( + [new Claim("iss", "bdgrz"), new Claim("sub", Uuid.CreateVersion4().ToString())], "BdgrzSession")); + + sealed class FakePermissionAuthorizer(bool allowed) : IPermissionAuthorizer + { + public ValueTask IsAllowedAsync( + Uuid tenantId, + Uuid memberId, + string permission, + CancellationToken ct = default) => ValueTask.FromResult(allowed); + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/RemoveTeamRoleRequestScenarioTests.cs b/test/Compliance.Tests/Features/AccessControl/RemoveTeamRoleRequestScenarioTests.cs new file mode 100644 index 0000000..2bccd5a --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/RemoveTeamRoleRequestScenarioTests.cs @@ -0,0 +1,74 @@ +using System.Security.Claims; +using Cntryl.Portia; +using Cntryl.Portia.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class RemoveTeamRoleRequestScenarioTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid TeamId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + + [Fact] + public async Task ShouldRemoveAnAssignedRoleGivenTenantRbacManagePermission() + { + await using var provider = BuildProvider(allowed: true); + await Seed(provider); + + await RequestScenario.For(provider) + .GivenActor(BdgrzActor()) + .When(new RemoveTeamRole(TenantId, TeamId, RoleId)) + .ExpectAuthorized() + .ExpectHandled() + .ExpectSuccess(); + } + + [Fact] + public async Task ShouldDenyGivenAnActorWithoutTheTenantRbacManagePermission() + { + await using var provider = BuildProvider(allowed: false); + await Seed(provider); + + await RequestScenario.For(provider) + .GivenActor(BdgrzActor()) + .When(new RemoveTeamRole(TenantId, TeamId, RoleId)) + .ExpectDenied(RequestErrorKind.Forbidden) + .ExpectNotHandled(); + } + + static async Task Seed(ServiceProvider provider) + { + await using var scope = provider.CreateAsyncScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + await executor.ExecuteAsync( + new TeamRole(TenantId, TeamId, RoleId), + teamRole => AggregateOutcome.Commit(teamRole.Assign()), + new RequestDispatchContext(RequestActor.System), + CancellationToken.None); + } + + static ServiceProvider BuildProvider(bool allowed) + { + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryEventStore()); + services.AddSingleton(new FakePermissionAuthorizer(allowed)); + services.AddPortia() + .AddRequestHandler() + .AddRequestAuthorizer(); + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + } + + static ClaimsPrincipal BdgrzActor() => new(new ClaimsIdentity( + [new Claim("iss", "bdgrz"), new Claim("sub", Uuid.CreateVersion4().ToString())], "BdgrzSession")); + + sealed class FakePermissionAuthorizer(bool allowed) : IPermissionAuthorizer + { + public ValueTask IsAllowedAsync( + Uuid tenantId, + Uuid memberId, + string permission, + CancellationToken ct = default) => ValueTask.FromResult(allowed); + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/RoleCleanupReactorTests.cs b/test/Compliance.Tests/Features/AccessControl/RoleCleanupReactorTests.cs new file mode 100644 index 0000000..25dc9d2 --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/RoleCleanupReactorTests.cs @@ -0,0 +1,121 @@ +using System.Security.Claims; +using Cntryl.Portia; +using Cntryl.Portia.Testing; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class RoleCleanupReactorTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + + [Fact] + public async Task ShouldRemoveEveryPermissionAndTeamAssignmentOfTheDeletedRole() + { + var firstTeam = Uuid.CreateVersion4(); + var secondTeam = Uuid.CreateVersion4(); + var permissions = new FakeRolePermissionDirectoryReader( + new RolePermissionView(RoleId, "controls.read"), new RolePermissionView(RoleId, "controls.manage")); + var teams = new FakeRoleTeamDirectoryReader( + new RoleTeamView(RoleId, firstTeam), new RoleTeamView(RoleId, secondTeam)); + var bus = new RecordingRequestBus(); + var reactor = new RoleCleanupReactor(new InMemoryProjectionCheckpointStore(), bus, permissions, teams); + var context = new FakeReactorContext(new RoleDeleted(TenantId, RoleId)); + + await reactor.HandleAsync(context, CancellationToken.None); + + Assert.Equal(4, bus.Dispatched.Count); + Assert.Contains(bus.Dispatched, request => + request is RemoveRolePermission removal && removal.Permission == "controls.read"); + Assert.Contains(bus.Dispatched, request => + request is RemoveRolePermission removal && removal.Permission == "controls.manage"); + Assert.Contains(bus.Dispatched, request => + request is RemoveTeamRole removal && removal.TeamId == firstTeam); + Assert.Contains(bus.Dispatched, request => + request is RemoveTeamRole removal && removal.TeamId == secondTeam); + } + + [Fact] + public async Task ShouldDispatchNothingGivenAnAlreadyEmptyRole() + { + var permissions = new FakeRolePermissionDirectoryReader(); + var teams = new FakeRoleTeamDirectoryReader(); + var bus = new RecordingRequestBus(); + var reactor = new RoleCleanupReactor(new InMemoryProjectionCheckpointStore(), bus, permissions, teams); + var context = new FakeReactorContext(new RoleDeleted(TenantId, RoleId)); + + await reactor.HandleAsync(context, CancellationToken.None); + + Assert.Empty(bus.Dispatched); + } + + sealed class FakeRolePermissionDirectoryReader(params RolePermissionView[] items) : IRolePermissionDirectoryReader + { + public ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) => + ValueTask.FromResult(new Page(items, null)); + } + + sealed class FakeRoleTeamDirectoryReader(params RoleTeamView[] items) : IRoleTeamDirectoryReader + { + public ValueTask> ListAsync( + Uuid tenantId, + Uuid roleId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) => + ValueTask.FromResult(new Page(items, null)); + } + + sealed class RecordingRequestBus : IRequestBus + { + public List Dispatched { get; } = []; + + public RequestDispatchContext CreateContext(ClaimsPrincipal actor, RequestMetadata? metadata = null) => + new(actor, metadata: metadata); + + public ValueTask AuthorizeAsync( + IRequestBase request, + RequestDispatchContext context, + CancellationToken ct = default) => throw new NotSupportedException(); + + public ValueTask DispatchAsync(IRequest request, RequestDispatchContext context, CancellationToken ct = default) + { + Dispatched.Add(request); + return ValueTask.FromResult(Result.Success); + } + + public ValueTask> DispatchAsync( + IRequest request, + RequestDispatchContext context, + CancellationToken ct = default) => throw new NotSupportedException(); + + public IAsyncEnumerable DispatchStreamAsync( + IStreamRequest request, + RequestDispatchContext context, + CancellationToken ct = default) => throw new NotSupportedException(); + } + + sealed class FakeReactorContext(RoleDeleted trigger) : IReactorContext + { + public RoleDeleted Trigger { get; } = trigger; + public DomainEventRecord Source { get; } = new( + new EventStreamAddress(TenantId.ToString(), "rbac-roles", RoleId.ToString()), + trigger, + 0, + EventCursor.Start); + public ClaimsPrincipal Actor => RequestActor.System; + public Uuid ExecutionId { get; } = Uuid.CreateVersion4(); + public Uuid CorrelationId { get; } = Uuid.CreateVersion4(); + public Uuid CauseId { get; } = Uuid.CreateVersion4(); + public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow; + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/RolePermissionTests.cs b/test/Compliance.Tests/Features/AccessControl/RolePermissionTests.cs new file mode 100644 index 0000000..4e0802f --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/RolePermissionTests.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using Cntryl.Portia; +using Cntryl.Portia.Testing; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class RolePermissionTests +{ + static readonly Uuid TenantId = Uuid.Parse("11f455d2-fb10-4f28-a157-23e18e706e70", CultureInfo.InvariantCulture); + static readonly Uuid RoleId = Uuid.Parse("c1b1b99d-2c52-46c6-ad58-55fe90bf53b3", CultureInfo.InvariantCulture); + const string Permission = "controls.read"; + + [Fact] + public void ShouldAssignIdempotently() + { + var rolePermission = new RolePermission(TenantId, RoleId, Permission); + var scenario = new AggregateScenario(rolePermission); + + var first = scenario.Aggregate.Assign(); + var second = scenario.Aggregate.Assign(); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + var assigned = Assert.Single(scenario.PendingEvents); + Assert.Equal("RolePermissionAssigned", assigned.GetType().Name); + } + + [Fact] + public void ShouldRemoveAnAssignedPermission() + { + var rolePermission = new RolePermission(TenantId, RoleId, Permission); + var scenario = new AggregateScenario(rolePermission) + .Given(DomainEventSeed.Attach( + new RolePermissionAssigned(TenantId, RoleId, Permission), rolePermission.Id, 1)); + + var result = scenario.Aggregate.Remove(); + + Assert.True(result.IsSuccess); + var removed = Assert.Single(scenario.PendingEvents); + Assert.Equal("RolePermissionRemoved", removed.GetType().Name); + } + + [Fact] + public void ShouldReturnNotFoundGivenAnUnassignedPermission() + { + var rolePermission = new RolePermission(TenantId, RoleId, Permission); + + var result = rolePermission.Remove(); + + Assert.False(result.IsSuccess); + Assert.Equal(RequestErrorKind.NotFound, result.Error.Kind); + } + + [Fact] + public void ShouldReturnNotFoundGivenARepeatedRemoval() + { + var rolePermission = new RolePermission(TenantId, RoleId, Permission); + var scenario = new AggregateScenario(rolePermission) + .Given( + DomainEventSeed.Attach(new RolePermissionAssigned(TenantId, RoleId, Permission), rolePermission.Id, 1), + DomainEventSeed.Attach(new RolePermissionRemoved(TenantId, RoleId, Permission), rolePermission.Id, 2)); + + var result = scenario.Aggregate.Remove(); + + Assert.False(result.IsSuccess); + Assert.Equal(RequestErrorKind.NotFound, result.Error.Kind); + } + + [Fact] + public void ShouldAllowReassigningAfterRemoval() + { + var rolePermission = new RolePermission(TenantId, RoleId, Permission); + var scenario = new AggregateScenario(rolePermission) + .Given( + DomainEventSeed.Attach(new RolePermissionAssigned(TenantId, RoleId, Permission), rolePermission.Id, 1), + DomainEventSeed.Attach(new RolePermissionRemoved(TenantId, RoleId, Permission), rolePermission.Id, 2)); + + var result = scenario.Aggregate.Assign(); + + Assert.True(result.IsSuccess); + var reassigned = Assert.Single(scenario.PendingEvents); + Assert.Equal("RolePermissionAssigned", reassigned.GetType().Name); + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/RoleQueryHandlerTests.cs b/test/Compliance.Tests/Features/AccessControl/RoleQueryHandlerTests.cs new file mode 100644 index 0000000..55e67ff --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/RoleQueryHandlerTests.cs @@ -0,0 +1,102 @@ +using System.Security.Claims; +using Cntryl.Portia; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class RoleQueryHandlerTests +{ + static readonly Uuid TenantId = Uuid.CreateVersion4(); + static readonly Uuid RoleId = Uuid.CreateVersion4(); + + [Fact] + public async Task GetRoleShouldReturnTheRoleGivenItExists() + { + var reader = new FakeRoleDirectoryReader(); + reader.Roles[(TenantId, RoleId)] = new RoleView(RoleId, "Reviewer"); + var handler = new GetRoleHandler(reader); + var context = new RequestContext(new GetRole(TenantId, RoleId), Actor()); + + var result = await handler.HandleAsync(context, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(new RoleView(RoleId, "Reviewer"), result.Value); + } + + [Fact] + public async Task GetRoleShouldReturnNotFoundGivenNoSuchRole() + { + var handler = new GetRoleHandler(new FakeRoleDirectoryReader()); + var context = new RequestContext(new GetRole(TenantId, RoleId), Actor()); + + var result = await handler.HandleAsync(context, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(RequestErrorKind.NotFound, result.Error.Kind); + } + + [Fact] + public async Task ListRolesShouldReturnThePageFromTheReader() + { + var reader = new FakeRoleDirectoryReader(); + reader.Roles[(TenantId, RoleId)] = new RoleView(RoleId, "Reviewer"); + var handler = new ListRolesHandler(reader); + var context = new RequestContext(new ListRoles(TenantId), Actor()); + + var result = await handler.HandleAsync(context, CancellationToken.None); + + Assert.True(result.IsSuccess); + var role = Assert.Single(result.Value.Items); + Assert.Equal(RoleId, role.RoleId); + Assert.Null(result.Value.NextCursor); + } + + [Fact] + public async Task ListRolesShouldBuildANormalizedQueryFromTheRequest() + { + var reader = new FakeRoleDirectoryReader(); + var handler = new ListRolesHandler(reader); + var context = new RequestContext( + new ListRoles(TenantId, Limit: 5, Cursor: "opaque", Search: " review ", Sort: "name:desc"), + Actor()); + + _ = await handler.HandleAsync(context, CancellationToken.None); + + Assert.Equal(5, reader.LastLimit); + Assert.Equal("opaque", reader.LastCursor); + Assert.Equal("review", reader.LastSearch); + Assert.True(reader.LastDescending); + } + + static ClaimsPrincipal Actor() => new(new ClaimsIdentity( + [new Claim("iss", "bdgrz"), new Claim("sub", Uuid.CreateVersion4().ToString())], "BdgrzSession")); + + sealed class FakeRoleDirectoryReader : IRoleDirectoryReader + { + public Dictionary<(Uuid TenantId, Uuid RoleId), RoleView> Roles { get; } = []; + public int? LastLimit { get; private set; } + public string? LastCursor { get; private set; } + public string? LastSearch { get; private set; } + public bool LastDescending { get; private set; } + + public ValueTask GetAsync(Uuid tenantId, Uuid roleId, CancellationToken ct = default) => + ValueTask.FromResult(Roles.TryGetValue((tenantId, roleId), out var role) ? role : null); + + public ValueTask> ListAsync( + Uuid tenantId, + int? limit, + string? cursor, + string? search, + bool descending, + CancellationToken ct = default) + { + LastLimit = limit; + LastCursor = cursor; + LastSearch = search; + LastDescending = descending; + IReadOnlyList items = + [.. Roles.Where(entry => entry.Key.TenantId == tenantId).Select(entry => entry.Value) + .Take(limit ?? 50)]; + return ValueTask.FromResult(new Page(items, null)); + } + } +} diff --git a/test/Compliance.Tests/Features/AccessControl/TeamRoleTests.cs b/test/Compliance.Tests/Features/AccessControl/TeamRoleTests.cs new file mode 100644 index 0000000..2f7639d --- /dev/null +++ b/test/Compliance.Tests/Features/AccessControl/TeamRoleTests.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using Cntryl.Portia; +using Cntryl.Portia.Testing; + +namespace Bdgrz.Compliance.Tests.Features.AccessControl; + +public sealed class TeamRoleTests +{ + static readonly Uuid TenantId = Uuid.Parse("11f455d2-fb10-4f28-a157-23e18e706e70", CultureInfo.InvariantCulture); + static readonly Uuid TeamId = Uuid.Parse("2cc8e854-a49a-42a1-9581-d0622de3d5c3", CultureInfo.InvariantCulture); + static readonly Uuid RoleId = Uuid.Parse("c1b1b99d-2c52-46c6-ad58-55fe90bf53b3", CultureInfo.InvariantCulture); + + [Fact] + public void ShouldAssignIdempotently() + { + var teamRole = new TeamRole(TenantId, TeamId, RoleId); + var scenario = new AggregateScenario(teamRole); + + var first = scenario.Aggregate.Assign(); + var second = scenario.Aggregate.Assign(); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + var assigned = Assert.Single(scenario.PendingEvents); + Assert.Equal("TeamRoleAssigned", assigned.GetType().Name); + } + + [Fact] + public void ShouldRemoveAnAssignedRole() + { + var teamRole = new TeamRole(TenantId, TeamId, RoleId); + var scenario = new AggregateScenario(teamRole) + .Given(DomainEventSeed.Attach(new TeamRoleAssigned(TenantId, TeamId, RoleId), teamRole.Id, 1)); + + var result = scenario.Aggregate.Remove(); + + Assert.True(result.IsSuccess); + var removed = Assert.Single(scenario.PendingEvents); + Assert.Equal("TeamRoleRemoved", removed.GetType().Name); + } + + [Fact] + public void ShouldReturnNotFoundGivenAnUnassignedRole() + { + var teamRole = new TeamRole(TenantId, TeamId, RoleId); + + var result = teamRole.Remove(); + + Assert.False(result.IsSuccess); + Assert.Equal(RequestErrorKind.NotFound, result.Error.Kind); + } + + [Fact] + public void ShouldReturnNotFoundGivenARepeatedRemoval() + { + var teamRole = new TeamRole(TenantId, TeamId, RoleId); + var scenario = new AggregateScenario(teamRole) + .Given( + DomainEventSeed.Attach(new TeamRoleAssigned(TenantId, TeamId, RoleId), teamRole.Id, 1), + DomainEventSeed.Attach(new TeamRoleRemoved(TenantId, TeamId, RoleId), teamRole.Id, 2)); + + var result = scenario.Aggregate.Remove(); + + Assert.False(result.IsSuccess); + Assert.Equal(RequestErrorKind.NotFound, result.Error.Kind); + } + + [Fact] + public void ShouldAllowReassigningAfterRemoval() + { + var teamRole = new TeamRole(TenantId, TeamId, RoleId); + var scenario = new AggregateScenario(teamRole) + .Given( + DomainEventSeed.Attach(new TeamRoleAssigned(TenantId, TeamId, RoleId), teamRole.Id, 1), + DomainEventSeed.Attach(new TeamRoleRemoved(TenantId, TeamId, RoleId), teamRole.Id, 2)); + + var result = scenario.Aggregate.Assign(); + + Assert.True(result.IsSuccess); + var reassigned = Assert.Single(scenario.PendingEvents); + Assert.Equal("TeamRoleAssigned", reassigned.GetType().Name); + } +} From 997ba7d1b7cddfaa65d1bd8b26f7c087f8986be0 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 18 Sep 2026 08:47:18 -0400 Subject: [PATCH 2/3] Fix MCP tool-list regression from Role management Adding Role's MCP tools in Program.cs broke TeamMcpScenarioTests.ShouldListTeamToolsAndDenyAnUnprivilegedCall, which asserted an exact tool list scoped to Team alone. Renamed to RbacMcpScenarioTests and updated the expected list to the full RBAC surface (Team + Role); the exact-match style is kept deliberately so future tool registrations force a visible review here rather than silently drifting. --- .../ComplianceBrokerIntegrationTests.cs | 0 ...enarioTests.cs => RbacMcpScenarioTests.cs} | 26 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) rename test/Compliance.Tests/{Hosting => E2E}/ComplianceBrokerIntegrationTests.cs (100%) rename test/Compliance.Tests/Features/AccessControl/{TeamMcpScenarioTests.cs => RbacMcpScenarioTests.cs} (73%) diff --git a/test/Compliance.Tests/Hosting/ComplianceBrokerIntegrationTests.cs b/test/Compliance.Tests/E2E/ComplianceBrokerIntegrationTests.cs similarity index 100% rename from test/Compliance.Tests/Hosting/ComplianceBrokerIntegrationTests.cs rename to test/Compliance.Tests/E2E/ComplianceBrokerIntegrationTests.cs diff --git a/test/Compliance.Tests/Features/AccessControl/TeamMcpScenarioTests.cs b/test/Compliance.Tests/Features/AccessControl/RbacMcpScenarioTests.cs similarity index 73% rename from test/Compliance.Tests/Features/AccessControl/TeamMcpScenarioTests.cs rename to test/Compliance.Tests/Features/AccessControl/RbacMcpScenarioTests.cs index 0d79a2a..e6e7eb9 100644 --- a/test/Compliance.Tests/Features/AccessControl/TeamMcpScenarioTests.cs +++ b/test/Compliance.Tests/Features/AccessControl/RbacMcpScenarioTests.cs @@ -12,15 +12,19 @@ namespace Bdgrz.Compliance.Tests.Features.AccessControl; /// -/// Exercises the Team domain's MCP surface through a real Streamable HTTP client — this is what -/// no test could verify before Portia 0.4's Mcp.Testing package: that the declared tools are -/// actually reachable, and that a call genuinely dispatches through the same authorization -/// pipeline as direct HTTP, not just that host startup didn't throw. +/// Exercises the RBAC domain's (Team + Role) MCP surface through a real Streamable HTTP client +/// — this is what no test could verify before Portia 0.4's Mcp.Testing package: that the +/// declared tools are actually reachable, and that a call genuinely dispatches through the +/// same authorization pipeline as direct HTTP, not just that host startup didn't throw. +/// is exact-match only, so this list must +/// be updated whenever a Program.cs AddMcpTool registration changes — that coupling is +/// deliberate: it forces a visible review of the registered surface instead of registration +/// drift going unnoticed. /// -public sealed class TeamMcpScenarioTests +public sealed class RbacMcpScenarioTests { [Fact] - public async Task ShouldListTeamToolsAndDenyAnUnprivilegedCall() + public async Task ShouldListRbacToolsAndDenyAnUnprivilegedCall() { await using var factory = CreateFactory(); using var client = factory.CreateClient(); @@ -40,6 +44,16 @@ public async Task ShouldListTeamToolsAndDenyAnUnprivilegedCall() "bdgrz.rbac.team-member.assign", "bdgrz.rbac.team-member.remove", "bdgrz.rbac.team-member.list", + "bdgrz.rbac.role.define", + "bdgrz.rbac.role.delete", + "bdgrz.rbac.role.get", + "bdgrz.rbac.role.list", + "bdgrz.rbac.role-permission.assign", + "bdgrz.rbac.role-permission.remove", + "bdgrz.rbac.role-permission.list", + "bdgrz.rbac.team-role.assign", + "bdgrz.rbac.team-role.remove", + "bdgrz.rbac.role-team.list", "bdgrz.tenant-membership.list-mine"); // No permission grant exists for this actor, so a call must fail the same way a direct From e369b90c8029caf6015824964df564b7b0927ac2 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sat, 19 Sep 2026 05:49:57 -0400 Subject: [PATCH 3/3] Align broker integration test namespace with folder --- test/Compliance.Tests/E2E/ComplianceBrokerIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Compliance.Tests/E2E/ComplianceBrokerIntegrationTests.cs b/test/Compliance.Tests/E2E/ComplianceBrokerIntegrationTests.cs index 6fdaa14..13f7c63 100644 --- a/test/Compliance.Tests/E2E/ComplianceBrokerIntegrationTests.cs +++ b/test/Compliance.Tests/E2E/ComplianceBrokerIntegrationTests.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -namespace Bdgrz.Compliance.Tests.Hosting; +namespace Bdgrz.Compliance.Tests.E2E; public sealed class ComplianceBrokerIntegrationTests {