Skip to content

Commit e2c6abb

Browse files
committed
Add BackOfficeAdmins capability and simplify mock identities to admin and plain user
1 parent 69ec714 commit e2c6abb

17 files changed

Lines changed: 260 additions & 86 deletions

File tree

.github/workflows/_deploy-infrastructure.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ on:
3737
back_office_entra_client_id:
3838
required: true
3939
type: string
40+
back_office_admins_group_id:
41+
required: false
42+
type: string
43+
default: "-"
4044
postgres_admin_object_id:
4145
required: true
4246
type: string
@@ -88,6 +92,7 @@ jobs:
8892
env:
8993
POSTGRES_ADMIN_OBJECT_ID: ${{ inputs.postgres_admin_object_id }}
9094
BACK_OFFICE_ENTRA_CLIENT_ID: ${{ inputs.back_office_entra_client_id }}
95+
BACK_OFFICE_ADMINS_GROUP_ID: ${{ inputs.back_office_admins_group_id }}
9196
GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }}
9297
GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
9398
STRIPE_PUBLISHABLE_KEY: ${{ vars.STRIPE_PUBLISHABLE_KEY }}
@@ -169,6 +174,7 @@ jobs:
169174
env:
170175
POSTGRES_ADMIN_OBJECT_ID: ${{ inputs.postgres_admin_object_id }}
171176
BACK_OFFICE_ENTRA_CLIENT_ID: ${{ inputs.back_office_entra_client_id }}
177+
BACK_OFFICE_ADMINS_GROUP_ID: ${{ inputs.back_office_admins_group_id }}
172178
GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }}
173179
GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
174180
STRIPE_PUBLISHABLE_KEY: ${{ vars.STRIPE_PUBLISHABLE_KEY }}

.github/workflows/cloud-infrastructure.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jobs:
3939
domain_name: ${{ vars.STAGING_DOMAIN_NAME }}
4040
back_office_domain_name: ${{ vars.STAGING_BACK_OFFICE_DOMAIN_NAME || '-' }}
4141
back_office_entra_client_id: ${{ vars.STAGING_BACK_OFFICE_ENTRA_CLIENT_ID }}
42+
back_office_admins_group_id: ${{ vars.STAGING_BACK_OFFICE_ADMINS_GROUP_ID || '-' }}
4243
postgres_admin_object_id: ${{ vars.STAGING_POSTGRES_ADMIN_OBJECT_ID }}
4344
production_service_principal_object_id: ${{ vars.PRODUCTION_SERVICE_PRINCIPAL_OBJECT_ID }}
4445

@@ -59,5 +60,6 @@ jobs:
5960
domain_name: ${{ vars.PRODUCTION_DOMAIN_NAME }}
6061
back_office_domain_name: ${{ vars.PRODUCTION_BACK_OFFICE_DOMAIN_NAME || '-' }}
6162
back_office_entra_client_id: ${{ vars.PRODUCTION_BACK_OFFICE_ENTRA_CLIENT_ID }}
63+
back_office_admins_group_id: ${{ vars.PRODUCTION_BACK_OFFICE_ADMINS_GROUP_ID || '-' }}
6264
postgres_admin_object_id: ${{ vars.PRODUCTION_POSTGRES_ADMIN_OBJECT_ID }}
6365
tenant_id: ${{ vars.TENANT_ID }}

application/AppHost/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using AppHost;
44
using Azure.Storage.Blobs;
55
using Projects;
6+
using SharedKernel.Authentication.MockEasyAuth;
67
using SharedKernel.Configuration;
78

89
// Read the port allocation before CreateBuilder so we can set Aspire's dashboard env vars
@@ -91,6 +92,7 @@
9192
.WithEnvironment("OAUTH_PUBLIC_URL", "https://localhost:" + ports.AppGateway)
9293
.WithEnvironment("Hostnames__App", appHostname)
9394
.WithEnvironment("BackOffice__Host", backOfficeHostname)
95+
.WithEnvironment("BackOffice__AdminsGroupId", MockEasyAuthIdentities.MockAdminsGroupId)
9496
.WithReference(accountDatabase)
9597
.WithReference(azureStorage)
9698
.WithEnvironment("OAuth__Google__ClientId", googleOAuthClientId)

application/account/BackOfficeWebApp/routes/login.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useState } from "react";
1111
import logoMarkUrl from "@/shared/images/logo-mark.svg";
1212
import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout";
1313

14-
const MOCK_IDENTITY_IDS = ["admin", "support", "readonly", "plain"] as const;
14+
const MOCK_IDENTITY_IDS = ["admin", "user"] as const;
1515

1616
interface MockLoginSearch {
1717
returnPath: string;
@@ -84,13 +84,9 @@ function MockLoginPage() {
8484
function getIdentityName(id: string) {
8585
switch (id) {
8686
case "admin":
87-
return <Trans>Admin User</Trans>;
88-
case "support":
89-
return <Trans>Support User</Trans>;
90-
case "readonly":
91-
return <Trans>Read Only</Trans>;
92-
case "plain":
93-
return <Trans>Plain User</Trans>;
87+
return <Trans>Admin</Trans>;
88+
case "user":
89+
return <Trans>User</Trans>;
9490
default:
9591
return id;
9692
}
@@ -100,11 +96,7 @@ function getIdentityDescription(id: string) {
10096
switch (id) {
10197
case "admin":
10298
return <Trans>Log in with admin rights</Trans>;
103-
case "support":
104-
return <Trans>Log in with support rights</Trans>;
105-
case "readonly":
106-
return <Trans>Log in with read-only rights</Trans>;
107-
case "plain":
99+
case "user":
108100
return <Trans>Log in without group claims</Trans>;
109101
default:
110102
return null;

application/account/BackOfficeWebApp/shared/translations/locale/da-DK.po

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ msgstr "Konti"
1919
msgid "Accounts (coming soon)"
2020
msgstr "Konti (kommer snart)"
2121

22-
msgid "Admin User"
23-
msgstr "Administrator"
22+
msgid "Admin"
23+
msgstr "Admin"
2424

2525
msgid "An unexpected error occurred while processing your request."
2626
msgstr "Der opstod en uventet fejl ved behandlingen."
@@ -91,12 +91,6 @@ msgstr "Log ind"
9191
msgid "Log in with admin rights"
9292
msgstr "Log ind med administratorrettigheder"
9393

94-
msgid "Log in with read-only rights"
95-
msgstr "Log ind med skrivebeskyttede rettigheder"
96-
97-
msgid "Log in with support rights"
98-
msgstr "Log ind med supportrettigheder"
99-
10094
msgid "Log in without group claims"
10195
msgstr "Log ind uden gruppeclaims"
10296

@@ -127,9 +121,6 @@ msgstr "Ingen"
127121
msgid "Page not found"
128122
msgstr "Siden blev ikke fundet"
129123

130-
msgid "Plain User"
131-
msgstr "Almindelig bruger"
132-
133124
msgid "PlatformPlatform logo"
134125
msgstr "PlatformPlatform logo"
135126

@@ -139,9 +130,6 @@ msgstr "Tjek venligst URL'en eller vend tilbage til forsiden."
139130
msgid "Please try again or return to the home page."
140131
msgstr "Prøv venligst igen eller vend tilbage til forsiden."
141132

142-
msgid "Read Only"
143-
msgstr "Skrivebeskyttet"
144-
145133
msgid "Screenshots of the dashboard project with desktop and mobile versions"
146134
msgstr "Skærmbilleder af dashboard-projektet i desktop- og mobilversioner"
147135

@@ -160,9 +148,6 @@ msgstr "Support"
160148
msgid "Support (coming soon)"
161149
msgstr "Support (kommer snart)"
162150

163-
msgid "Support User"
164-
msgstr "Supportbruger"
165-
166151
msgid "System"
167152
msgstr "System"
168153

@@ -175,6 +160,9 @@ msgstr "Tema"
175160
msgid "Try again"
176161
msgstr "Prøv igen"
177162

163+
msgid "User"
164+
msgstr "Bruger"
165+
178166
msgid "User menu"
179167
msgstr "Brugermenu"
180168

application/account/BackOfficeWebApp/shared/translations/locale/en-US.po

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ msgstr "Accounts"
1919
msgid "Accounts (coming soon)"
2020
msgstr "Accounts (coming soon)"
2121

22-
msgid "Admin User"
23-
msgstr "Admin User"
22+
msgid "Admin"
23+
msgstr "Admin"
2424

2525
msgid "An unexpected error occurred while processing your request."
2626
msgstr "An unexpected error occurred while processing your request."
@@ -91,12 +91,6 @@ msgstr "Log in"
9191
msgid "Log in with admin rights"
9292
msgstr "Log in with admin rights"
9393

94-
msgid "Log in with read-only rights"
95-
msgstr "Log in with read-only rights"
96-
97-
msgid "Log in with support rights"
98-
msgstr "Log in with support rights"
99-
10094
msgid "Log in without group claims"
10195
msgstr "Log in without group claims"
10296

@@ -127,9 +121,6 @@ msgstr "None"
127121
msgid "Page not found"
128122
msgstr "Page not found"
129123

130-
msgid "Plain User"
131-
msgstr "Plain User"
132-
133124
msgid "PlatformPlatform logo"
134125
msgstr "PlatformPlatform logo"
135126

@@ -139,9 +130,6 @@ msgstr "Please check the URL or return to the home page."
139130
msgid "Please try again or return to the home page."
140131
msgstr "Please try again or return to the home page."
141132

142-
msgid "Read Only"
143-
msgstr "Read Only"
144-
145133
msgid "Screenshots of the dashboard project with desktop and mobile versions"
146134
msgstr "Screenshots of the dashboard project with desktop and mobile versions"
147135

@@ -160,9 +148,6 @@ msgstr "Support"
160148
msgid "Support (coming soon)"
161149
msgstr "Support (coming soon)"
162150

163-
msgid "Support User"
164-
msgstr "Support User"
165-
166151
msgid "System"
167152
msgstr "System"
168153

@@ -175,6 +160,9 @@ msgstr "Theme"
175160
msgid "Try again"
176161
msgstr "Try again"
177162

163+
msgid "User"
164+
msgstr "User"
165+
178166
msgid "User menu"
179167
msgstr "User menu"
180168

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System.Security.Claims;
2+
using FluentAssertions;
3+
using Microsoft.AspNetCore.Authorization;
4+
using Microsoft.Extensions.Options;
5+
using SharedKernel.Authentication.BackOfficeIdentity;
6+
using Xunit;
7+
8+
namespace Account.Tests.BackOffice;
9+
10+
// Verifies the optional BackOffice:AdminsGroupId capability gate. When unset, no one is admin.
11+
// When set, the principal must carry a 'groups' claim matching the configured value.
12+
public sealed class BackOfficeAdminRequirementTests
13+
{
14+
[Fact]
15+
public async Task HandleRequirement_WhenAdminsGroupIdUnset_ShouldFail()
16+
{
17+
var handler = CreateHandler(null);
18+
var requirement = new BackOfficeAdminRequirement();
19+
var context = CreateAuthorizationContext(requirement, ["BackOfficeAdmins"]);
20+
21+
await handler.HandleAsync(context);
22+
23+
context.HasSucceeded.Should().BeFalse();
24+
}
25+
26+
[Fact]
27+
public async Task HandleRequirement_WhenPrincipalCarriesMatchingGroup_ShouldSucceed()
28+
{
29+
var handler = CreateHandler("BackOfficeAdmins");
30+
var requirement = new BackOfficeAdminRequirement();
31+
var context = CreateAuthorizationContext(requirement, ["BackOfficeAdmins"]);
32+
33+
await handler.HandleAsync(context);
34+
35+
context.HasSucceeded.Should().BeTrue();
36+
}
37+
38+
[Fact]
39+
public async Task HandleRequirement_WhenPrincipalLacksMatchingGroup_ShouldFail()
40+
{
41+
var handler = CreateHandler("BackOfficeAdmins");
42+
var requirement = new BackOfficeAdminRequirement();
43+
var context = CreateAuthorizationContext(requirement, []);
44+
45+
await handler.HandleAsync(context);
46+
47+
context.HasSucceeded.Should().BeFalse();
48+
}
49+
50+
private static BackOfficeAdminAuthorizationHandler CreateHandler(string? adminsGroupId)
51+
{
52+
var options = Options.Create(new BackOfficeHostOptions { Host = "back-office.test.localhost", AdminsGroupId = adminsGroupId });
53+
return new BackOfficeAdminAuthorizationHandler(options);
54+
}
55+
56+
private static AuthorizationHandlerContext CreateAuthorizationContext(IAuthorizationRequirement requirement, string[] groups)
57+
{
58+
var claims = groups.Select(group => new Claim(BackOfficeIdentityDefaults.GroupsClaimType, group));
59+
var identity = new ClaimsIdentity(claims, BackOfficeIdentityDefaults.AuthenticationScheme);
60+
var principal = new ClaimsPrincipal(identity);
61+
return new AuthorizationHandlerContext([requirement], principal, null);
62+
}
63+
}

application/account/WebApp/tests/e2e/back-office-flows.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ test.describe("@smoke", () => {
4141

4242
await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/login?returnPath=%2Fapi%2Fback-office%2Fme`);
4343
await expect(page.getByRole("heading", { name: "BackOffice - Localhost" })).toBeVisible();
44-
await expect(page.getByRole("radio", { name: "Admin User Log in with admin rights" })).toBeVisible();
44+
await expect(page.getByRole("radio", { name: "Admin Log in with admin rights" })).toBeVisible();
4545
}
4646
)();
4747

4848
await step("Pick the Admin identity & verify callback redirects back to the protected endpoint")(async () => {
49-
await page.getByRole("radio", { name: "Admin User Log in with admin rights" }).click();
49+
await page.getByRole("radio", { name: "Admin Log in with admin rights" }).click();
5050
await page.getByRole("button", { name: "Log in" }).click();
5151

5252
await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/api/back-office/me`);
@@ -59,7 +59,7 @@ test.describe("@smoke", () => {
5959

6060
expect(response.status()).toBe(200);
6161
const payload = await response.json();
62-
expect(payload.displayName).toBe("Admin User");
62+
expect(payload.displayName).toBe("Admin");
6363
expect(payload.groups).toContain("BackOfficeAdmins");
6464
})();
6565

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.Extensions.Options;
3+
4+
namespace SharedKernel.Authentication.BackOfficeIdentity;
5+
6+
public sealed class BackOfficeAdminRequirement : IAuthorizationRequirement;
7+
8+
// Authorization handler enforcing the optional back-office admin capability. Easy Auth gates the
9+
// container app to authenticated tenant users; this requirement adds an opt-in admin gate on top.
10+
// When BackOffice:AdminsGroupId is unset, no one passes (admin features are off). When set, the
11+
// principal must carry a 'groups' claim matching the configured value.
12+
public sealed class BackOfficeAdminAuthorizationHandler(IOptions<BackOfficeHostOptions> options)
13+
: AuthorizationHandler<BackOfficeAdminRequirement>
14+
{
15+
private readonly BackOfficeHostOptions _options = options.Value;
16+
17+
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, BackOfficeAdminRequirement requirement)
18+
{
19+
if (string.IsNullOrWhiteSpace(_options.AdminsGroupId)) return Task.CompletedTask;
20+
21+
var hasGroup = context.User.Claims.Any(claim =>
22+
claim.Type == BackOfficeIdentityDefaults.GroupsClaimType &&
23+
string.Equals(claim.Value, _options.AdminsGroupId, StringComparison.Ordinal)
24+
);
25+
26+
if (hasGroup) context.Succeed(requirement);
27+
return Task.CompletedTask;
28+
}
29+
}

application/shared-kernel/SharedKernel/Authentication/BackOfficeIdentity/BackOfficeHostOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ public sealed class BackOfficeHostOptions
88

99
[Required(AllowEmptyStrings = false)]
1010
public string Host { get; init; } = string.Empty;
11+
12+
public string? AdminsGroupId { get; init; }
1113
}

0 commit comments

Comments
 (0)