From 0787734848573bda02bc2f512f9d1a26cb1c9c94 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Sat, 4 Apr 2026 10:23:44 +1100
Subject: [PATCH 01/59] Add gatekeeper
---
.../AuthenticationTests.cs | 306 ++++++++
.../AuthorizationTests.cs | 646 ++++++++++++++++
.../Gatekeeper.Api.Tests.csproj | 35 +
.../Gatekeeper.Api.Tests/GlobalUsings.cs | 36 +
.../Gatekeeper.Api.Tests/TokenServiceTests.cs | 597 +++++++++++++++
.../Gatekeeper.Api/AuthorizationService.cs | 113 +++
Gatekeeper/Gatekeeper.Api/DataProvider.json | 34 +
Gatekeeper/Gatekeeper.Api/DatabaseSetup.cs | 148 ++++
.../Gatekeeper.Api/FileLoggerProvider.cs | 109 +++
.../Gatekeeper.Api/Gatekeeper.Api.csproj | 67 ++
.../Gatekeeper.Api/Generated/.timestamp | 0
.../Generated/CheckPermission.g.cs | 107 +++
.../Generated/CheckResourceGrant.g.cs | 151 ++++
.../Generated/CountSystemRoles.g.cs | 70 ++
.../Generated/GetActivePolicies.g.cs | 127 ++++
.../Generated/GetAllPermissions.g.cs | 102 +++
.../Gatekeeper.Api/Generated/GetAllRoles.g.cs | 102 +++
.../Gatekeeper.Api/Generated/GetAllUsers.g.cs | 102 +++
.../Generated/GetChallengeById.g.cs | 112 +++
.../Generated/GetCredentialById.g.cs | 164 ++++
.../Generated/GetCredentialsByUserId.g.cs | 151 ++++
.../Generated/GetPermissionByCode.g.cs | 107 +++
.../Generated/GetRolePermissions.g.cs | 115 +++
.../Generated/GetSessionById.g.cs | 145 ++++
.../Generated/GetSessionForRevoke.g.cs | 127 ++++
.../Generated/GetSessionRevoked.g.cs | 76 ++
.../Generated/GetUserByEmail.g.cs | 113 +++
.../Gatekeeper.Api/Generated/GetUserById.g.cs | 113 +++
.../Generated/GetUserCredentials.g.cs | 150 ++++
.../Generated/GetUserPermissions.g.cs | 152 ++++
.../Generated/GetUserRoles.g.cs | 114 +++
.../Generated/RevokeSession.g.cs | 82 ++
.../Generated/gk_challengeOperations.g.cs | 52 ++
.../Generated/gk_credentialOperations.g.cs | 59 ++
.../Generated/gk_permissionOperations.g.cs | 52 ++
.../gk_resource_grantOperations.g.cs | 54 ++
.../Generated/gk_roleOperations.g.cs | 52 ++
.../gk_role_permissionOperations.g.cs | 49 ++
.../Generated/gk_sessionOperations.g.cs | 90 +++
.../Generated/gk_userOperations.g.cs | 53 ++
.../Generated/gk_user_roleOperations.g.cs | 51 ++
Gatekeeper/Gatekeeper.Api/GlobalUsings.cs | 59 ++
Gatekeeper/Gatekeeper.Api/Program.cs | 716 ++++++++++++++++++
.../Properties/launchSettings.json | 14 +
.../Gatekeeper.Api/Sql/CheckPermission.sql | 24 +
.../Gatekeeper.Api/Sql/CheckResourceGrant.sql | 10 +
.../Gatekeeper.Api/Sql/CountSystemRoles.sql | 2 +
.../Gatekeeper.Api/Sql/GetActivePolicies.sql | 7 +
.../Gatekeeper.Api/Sql/GetAllPermissions.sql | 4 +
Gatekeeper/Gatekeeper.Api/Sql/GetAllRoles.sql | 4 +
Gatekeeper/Gatekeeper.Api/Sql/GetAllUsers.sql | 4 +
.../Gatekeeper.Api/Sql/GetChallengeById.sql | 4 +
.../Gatekeeper.Api/Sql/GetCredentialById.sql | 7 +
.../Sql/GetCredentialsByUserId.sql | 6 +
.../Sql/GetPermissionByCode.sql | 4 +
.../Gatekeeper.Api/Sql/GetRolePermissions.sql | 6 +
.../Gatekeeper.Api/Sql/GetSessionById.sql | 7 +
.../Sql/GetSessionForRevoke.sql | 6 +
.../Gatekeeper.Api/Sql/GetSessionRevoked.sql | 3 +
.../Gatekeeper.Api/Sql/GetUserByEmail.sql | 4 +
Gatekeeper/Gatekeeper.Api/Sql/GetUserById.sql | 4 +
.../Gatekeeper.Api/Sql/GetUserCredentials.sql | 5 +
.../Gatekeeper.Api/Sql/GetUserPermissions.sql | 26 +
.../Gatekeeper.Api/Sql/GetUserRoles.sql | 6 +
.../Gatekeeper.Api/Sql/RevokeSession.sql | 3 +
Gatekeeper/Gatekeeper.Api/TokenService.cs | 191 +++++
.../Gatekeeper.Api/gatekeeper-schema.yaml | 397 ++++++++++
Gatekeeper/Gatekeeper.Api/gatekeeper.db | Bin 0 -> 147456 bytes
Gatekeeper/README.md | 13 +
HealthcareSamples.sln | 145 +++-
70 files changed, 6765 insertions(+), 1 deletion(-)
create mode 100644 Gatekeeper/Gatekeeper.Api.Tests/AuthenticationTests.cs
create mode 100644 Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs
create mode 100644 Gatekeeper/Gatekeeper.Api.Tests/Gatekeeper.Api.Tests.csproj
create mode 100644 Gatekeeper/Gatekeeper.Api.Tests/GlobalUsings.cs
create mode 100644 Gatekeeper/Gatekeeper.Api.Tests/TokenServiceTests.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/AuthorizationService.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/DataProvider.json
create mode 100644 Gatekeeper/Gatekeeper.Api/DatabaseSetup.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Gatekeeper.Api.csproj
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/.timestamp
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/CheckPermission.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/CheckResourceGrant.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/CountSystemRoles.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetActivePolicies.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetAllPermissions.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetAllRoles.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetAllUsers.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetChallengeById.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetCredentialById.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetCredentialsByUserId.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetPermissionByCode.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetRolePermissions.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetSessionById.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetSessionForRevoke.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetSessionRevoked.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetUserByEmail.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetUserById.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetUserCredentials.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetUserPermissions.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/GetUserRoles.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/RevokeSession.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_challengeOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_credentialOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_permissionOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_resource_grantOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_roleOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_role_permissionOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_sessionOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_userOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Generated/gk_user_roleOperations.g.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/GlobalUsings.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Program.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/Properties/launchSettings.json
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/CheckPermission.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/CheckResourceGrant.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/CountSystemRoles.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetActivePolicies.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetAllPermissions.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetAllRoles.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetAllUsers.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetChallengeById.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetCredentialById.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetCredentialsByUserId.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetPermissionByCode.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetRolePermissions.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetSessionById.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetSessionForRevoke.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetSessionRevoked.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetUserByEmail.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetUserById.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetUserCredentials.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetUserPermissions.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/GetUserRoles.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/Sql/RevokeSession.sql
create mode 100644 Gatekeeper/Gatekeeper.Api/TokenService.cs
create mode 100644 Gatekeeper/Gatekeeper.Api/gatekeeper-schema.yaml
create mode 100644 Gatekeeper/Gatekeeper.Api/gatekeeper.db
create mode 100644 Gatekeeper/README.md
diff --git a/Gatekeeper/Gatekeeper.Api.Tests/AuthenticationTests.cs b/Gatekeeper/Gatekeeper.Api.Tests/AuthenticationTests.cs
new file mode 100644
index 0000000..92da28e
--- /dev/null
+++ b/Gatekeeper/Gatekeeper.Api.Tests/AuthenticationTests.cs
@@ -0,0 +1,306 @@
+namespace Gatekeeper.Api.Tests;
+
+///
+/// Integration tests for Gatekeeper authentication endpoints.
+/// Tests WebAuthn/FIDO2 passkey registration and login flows.
+///
+public sealed class AuthenticationTests : IClassFixture
+{
+ private readonly HttpClient _client;
+
+ public AuthenticationTests(GatekeeperTestFixture fixture)
+ {
+ _client = fixture.CreateClient();
+ }
+
+ [Fact]
+ public async Task RegisterBegin_WithValidEmail_ReturnsChallenge()
+ {
+ var request = new { Email = "test@example.com", DisplayName = "Test User" };
+
+ var response = await _client.PostAsJsonAsync("/auth/register/begin", request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+
+ Assert.True(doc.RootElement.TryGetProperty("ChallengeId", out var challengeId));
+ Assert.False(string.IsNullOrEmpty(challengeId.GetString()));
+
+ // API returns OptionsJson as a JSON string (for JS to parse)
+ Assert.True(doc.RootElement.TryGetProperty("OptionsJson", out var optionsJson));
+ var parsedOptions = JsonDocument.Parse(optionsJson.GetString()!);
+ Assert.True(parsedOptions.RootElement.TryGetProperty("challenge", out _));
+ }
+
+ [Fact]
+ public async Task RegisterBegin_RequiresResidentKey_ForDiscoverableCredentials()
+ {
+ // Registration must require resident keys so login works without email
+ var request = new { Email = "resident@example.com", DisplayName = "Resident User" };
+
+ var response = await _client.PostAsJsonAsync("/auth/register/begin", request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+ var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!;
+ var options = JsonDocument.Parse(optionsJson);
+
+ // Verify authenticatorSelection requires resident key
+ Assert.True(
+ options.RootElement.TryGetProperty("authenticatorSelection", out var authSelection)
+ );
+ Assert.True(authSelection.TryGetProperty("residentKey", out var residentKey));
+ Assert.Equal("required", residentKey.GetString());
+ }
+
+ [Fact]
+ public async Task RegisterBegin_RequiresUserVerification()
+ {
+ // Registration must require user verification for security
+ var request = new { Email = "verify@example.com", DisplayName = "Verify User" };
+
+ var response = await _client.PostAsJsonAsync("/auth/register/begin", request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+ var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!;
+ var options = JsonDocument.Parse(optionsJson);
+
+ var authSelection = options.RootElement.GetProperty("authenticatorSelection");
+ Assert.True(authSelection.TryGetProperty("userVerification", out var userVerification));
+ Assert.Equal("required", userVerification.GetString());
+ }
+
+ [Fact]
+ public async Task LoginBegin_WithEmptyBody_ReturnsChallenge_ForDiscoverableCredentials()
+ {
+ // Discoverable credentials flow: no email needed, browser shows all passkeys
+ // Server returns challenge with empty allowCredentials
+ var response = await _client.PostAsJsonAsync("/auth/login/begin", new { });
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+
+ // Should return a valid challenge
+ Assert.True(doc.RootElement.TryGetProperty("ChallengeId", out var challengeId));
+ Assert.False(string.IsNullOrEmpty(challengeId.GetString()));
+
+ // Verify options structure
+ Assert.True(doc.RootElement.TryGetProperty("OptionsJson", out var optionsJson));
+ var options = JsonDocument.Parse(optionsJson.GetString()!);
+ Assert.True(options.RootElement.TryGetProperty("challenge", out _));
+
+ // allowCredentials should be empty for discoverable credentials
+ Assert.True(
+ options.RootElement.TryGetProperty("allowCredentials", out var allowCredentials)
+ );
+ Assert.Equal(JsonValueKind.Array, allowCredentials.ValueKind);
+ Assert.Equal(0, allowCredentials.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task LoginBegin_RequiresUserVerification()
+ {
+ // Login must require user verification (Touch ID, Face ID, etc.)
+ var response = await _client.PostAsJsonAsync("/auth/login/begin", new { });
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+ var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!;
+ var options = JsonDocument.Parse(optionsJson);
+
+ Assert.True(
+ options.RootElement.TryGetProperty("userVerification", out var userVerification)
+ );
+ Assert.Equal("required", userVerification.GetString());
+ }
+
+ [Fact]
+ public async Task LoginComplete_WithInvalidChallengeId_ReturnsError()
+ {
+ // Attempting to complete login with invalid challenge should fail
+ // The endpoint validates the challenge ID and returns an error
+ var request = new
+ {
+ ChallengeId = "non-existent-challenge-id",
+ OptionsJson = "{}",
+ AssertionResponse = new
+ {
+ Id = "ZmFrZS1jcmVkZW50aWFsLWlk", // base64url encoded
+ RawId = "ZmFrZS1jcmVkZW50aWFsLWlk",
+ Type = "public-key",
+ Response = new
+ {
+ AuthenticatorData = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ ClientDataJson = "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYWFhYSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyJ9",
+ Signature = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ UserHandle = (string?)null,
+ },
+ },
+ };
+
+ var response = await _client.PostAsJsonAsync("/auth/login/complete", request);
+
+ // Should return an error (either BadRequest for validation or Problem for processing)
+ Assert.True(
+ response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError,
+ $"Expected error status code but got {response.StatusCode}"
+ );
+ }
+
+ [Fact]
+ public async Task RegisterComplete_WithInvalidChallengeId_ReturnsError()
+ {
+ // Attempting to complete registration with invalid challenge should fail
+ var request = new
+ {
+ ChallengeId = "non-existent-challenge-id",
+ OptionsJson = "{}",
+ AttestationResponse = new
+ {
+ Id = "ZmFrZS1jcmVkZW50aWFsLWlk", // base64url encoded
+ RawId = "ZmFrZS1jcmVkZW50aWFsLWlk",
+ Type = "public-key",
+ Response = new
+ {
+ AttestationObject = "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjE",
+ ClientDataJson = "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYWFhYSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyJ9",
+ },
+ },
+ };
+
+ var response = await _client.PostAsJsonAsync("/auth/register/complete", request);
+
+ // Should return an error (either BadRequest for validation or Problem for processing)
+ Assert.True(
+ response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError,
+ $"Expected error status code but got {response.StatusCode}"
+ );
+ }
+
+ [Fact]
+ public async Task Session_WithoutToken_ReturnsUnauthorized()
+ {
+ var response = await _client.GetAsync("/auth/session");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Session_WithInvalidToken_ReturnsUnauthorized()
+ {
+ _client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid-token");
+
+ var response = await _client.GetAsync("/auth/session");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Logout_WithoutToken_ReturnsUnauthorized()
+ {
+ var response = await _client.PostAsync("/auth/logout", null);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+}
+
+///
+/// Tests for Base64Url encoding used in WebAuthn credential IDs.
+///
+public sealed class Base64UrlTests
+{
+ [Fact]
+ public void Encode_ProducesUrlSafeOutput()
+ {
+ // Standard base64 uses + and /, base64url uses - and _
+ var input = new byte[] { 0xfb, 0xff, 0xfe }; // Would produce +//+ in standard base64
+
+ var result = Base64Url.Encode(input);
+
+ Assert.DoesNotContain("+", result);
+ Assert.DoesNotContain("/", result);
+ Assert.DoesNotContain("=", result);
+ Assert.Contains("-", result); // Should use - instead of +
+ Assert.Contains("_", result); // Should use _ instead of /
+ }
+
+ [Fact]
+ public void Encode_Decode_RoundTrip()
+ {
+ var original = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
+
+ var encoded = Base64Url.Encode(original);
+ var decoded = Base64Url.Decode(encoded);
+
+ Assert.Equal(original, decoded);
+ }
+
+ [Fact]
+ public void Decode_HandlesNoPadding()
+ {
+ // base64url typically omits padding
+ var encoded = "AQIDBA"; // No = padding
+
+ var decoded = Base64Url.Decode(encoded);
+
+ Assert.Equal(new byte[] { 1, 2, 3, 4 }, decoded);
+ }
+
+ [Fact]
+ public void Decode_HandlesUrlSafeCharacters()
+ {
+ // Test decoding with - and _ (url-safe chars)
+ var encoded = "-_8"; // base64url for 0xfb, 0xff
+
+ var decoded = Base64Url.Decode(encoded);
+
+ Assert.Equal(new byte[] { 0xfb, 0xff }, decoded);
+ }
+
+ [Fact]
+ public void Encode_MatchesWebAuthnCredentialIdFormat()
+ {
+ // WebAuthn credential IDs use base64url encoding
+ // This test verifies our encoding matches the expected format
+ var credentialId = new byte[]
+ {
+ 0x01,
+ 0x02,
+ 0x03,
+ 0x04,
+ 0x05,
+ 0x06,
+ 0x07,
+ 0x08,
+ 0x09,
+ 0x0a,
+ 0x0b,
+ 0x0c,
+ 0x0d,
+ 0x0e,
+ 0x0f,
+ 0x10,
+ };
+
+ var encoded = Base64Url.Encode(credentialId);
+
+ // Should be AQIDBAUGBwgJCgsMDQ4PEA (no padding)
+ Assert.Equal("AQIDBAUGBwgJCgsMDQ4PEA", encoded);
+
+ // Verify round-trip
+ var decoded = Base64Url.Decode(encoded);
+ Assert.Equal(credentialId, decoded);
+ }
+}
diff --git a/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs b/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs
new file mode 100644
index 0000000..aac8f2e
--- /dev/null
+++ b/Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs
@@ -0,0 +1,646 @@
+using System.Globalization;
+using Npgsql;
+using Outcome;
+
+namespace Gatekeeper.Api.Tests;
+
+///
+/// Integration tests for Gatekeeper authorization endpoints.
+/// Tests RBAC permission checks, resource grants, and bulk evaluation.
+///
+public sealed class AuthorizationTests : IClassFixture
+{
+ private readonly GatekeeperTestFixture _fixture;
+
+ public AuthorizationTests(GatekeeperTestFixture fixture) => _fixture = fixture;
+
+ [Fact]
+ public async Task Check_WithoutToken_ReturnsUnauthorized()
+ {
+ var client = _fixture.CreateClient();
+
+ var response = await client.GetAsync("/authz/check?permission=test:read");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Check_WithInvalidToken_ReturnsUnauthorized()
+ {
+ var client = _fixture.CreateClient();
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid-token");
+
+ var response = await client.GetAsync("/authz/check?permission=test:read");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Check_WithValidToken_UserHasDefaultPermissions_ReturnsAllowed()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateTestUserAndGetToken("authz-user-1@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ // Debug: Check what's in the database using DataProvider extensions
+ using var conn = _fixture.OpenConnection();
+ var rolePermsResult = await conn.GetRolePermissionsAsync("role-user");
+ var rolePerms = rolePermsResult switch
+ {
+ GetRolePermissionsOk ok => ok.Value.Select(p => $"role-user->{p.code}").ToList(),
+ GetRolePermissionsError err => [$"(error: {err.Value.Message})"],
+ };
+
+ // Default 'user' role has 'user:profile' permission
+ var response = await client.GetAsync("/authz/check?permission=user:profile");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+ Assert.True(
+ doc.RootElement.GetProperty("Allowed").GetBoolean(),
+ $"Response: {content}, RolePerms: [{string.Join(", ", rolePerms)}]"
+ );
+ Assert.Contains("user:profile", doc.RootElement.GetProperty("Reason").GetString());
+ }
+
+ [Fact]
+ public async Task Check_WithValidToken_UserLacksPermission_ReturnsDenied()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateTestUserAndGetToken("authz-user-2@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ // Default 'user' role does NOT have 'admin:users' permission
+ var response = await client.GetAsync("/authz/check?permission=admin:users");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+ Assert.False(doc.RootElement.GetProperty("Allowed").GetBoolean());
+ Assert.Equal("no matching permission", doc.RootElement.GetProperty("Reason").GetString());
+ }
+
+ [Fact]
+ public async Task Check_AdminWildcardPermission_MatchesSubPermissions()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateAdminUserAndGetToken("admin-wildcard@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ // Admin role has 'admin:*' which should match 'admin:users'
+ var response = await client.GetAsync("/authz/check?permission=admin:users");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+ Assert.True(doc.RootElement.GetProperty("Allowed").GetBoolean());
+ Assert.Contains("admin", doc.RootElement.GetProperty("Reason").GetString());
+ }
+
+ [Fact]
+ public async Task Check_AdminWildcardPermission_MatchesNestedSubPermissions()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateAdminUserAndGetToken("admin-nested@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ // Admin role has 'admin:*' which should match 'admin:users:create'
+ var response = await client.GetAsync("/authz/check?permission=admin:users:create");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+ Assert.True(doc.RootElement.GetProperty("Allowed").GetBoolean());
+ }
+
+ [Fact]
+ public async Task Permissions_WithoutToken_ReturnsUnauthorized()
+ {
+ var client = _fixture.CreateClient();
+
+ var response = await client.GetAsync("/authz/permissions");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Permissions_WithValidToken_ReturnsUserPermissions()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateTestUserAndGetToken("authz-perms@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ var response = await client.GetAsync("/authz/permissions");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+
+ Assert.True(doc.RootElement.TryGetProperty("Permissions", out var perms));
+ Assert.Equal(JsonValueKind.Array, perms.ValueKind);
+
+ // Default user role has 'user:profile' and 'user:credentials'
+ var permCodes = perms
+ .EnumerateArray()
+ .Select(p => p.GetProperty("code").GetString())
+ .ToList();
+ Assert.Contains("user:profile", permCodes);
+ Assert.Contains("user:credentials", permCodes);
+ }
+
+ [Fact]
+ public async Task Permissions_AdminUser_ReturnsAdminPermissions()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateAdminUserAndGetToken("admin-perms@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ var response = await client.GetAsync("/authz/permissions");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+
+ var perms = doc.RootElement.GetProperty("Permissions");
+ var permCodes = perms
+ .EnumerateArray()
+ .Select(p => p.GetProperty("code").GetString())
+ .ToList();
+ Assert.Contains("admin:*", permCodes);
+ }
+
+ [Fact]
+ public async Task Evaluate_WithoutToken_ReturnsUnauthorized()
+ {
+ var client = _fixture.CreateClient();
+
+ var request = new
+ {
+ Checks = new[]
+ {
+ new
+ {
+ Permission = "test:read",
+ ResourceType = (string?)null,
+ ResourceId = (string?)null,
+ },
+ },
+ };
+ var response = await client.PostAsJsonAsync("/authz/evaluate", request);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Evaluate_WithValidToken_ReturnsBulkResults()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateTestUserAndGetToken("authz-eval@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ var request = new
+ {
+ Checks = new[]
+ {
+ new
+ {
+ Permission = "user:profile",
+ ResourceType = (string?)null,
+ ResourceId = (string?)null,
+ },
+ new
+ {
+ Permission = "admin:users",
+ ResourceType = (string?)null,
+ ResourceId = (string?)null,
+ },
+ new
+ {
+ Permission = "user:credentials",
+ ResourceType = (string?)null,
+ ResourceId = (string?)null,
+ },
+ },
+ };
+
+ var response = await client.PostAsJsonAsync("/authz/evaluate", request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(content);
+
+ Assert.True(doc.RootElement.TryGetProperty("Results", out var results));
+ Assert.Equal(3, results.GetArrayLength());
+
+ var resultsList = results.EnumerateArray().ToList();
+
+ // user:profile - allowed
+ Assert.True(resultsList[0].GetProperty("Allowed").GetBoolean());
+
+ // admin:users - denied
+ Assert.False(resultsList[1].GetProperty("Allowed").GetBoolean());
+
+ // user:credentials - allowed
+ Assert.True(resultsList[2].GetProperty("Allowed").GetBoolean());
+ }
+
+ [Fact]
+ public async Task Evaluate_EmptyChecks_ReturnsEmptyResults()
+ {
+ var client = _fixture.CreateClient();
+ var token = await _fixture.CreateTestUserAndGetToken("authz-empty@example.com");
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+
+ var request = new { Checks = Array.Empty