From 96bea1d478c567a0d588b09565039c18ca162a25 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 22:30:25 -0400 Subject: [PATCH] Apply technical-light design system to the gateway dashboard Restyles the Blazor dashboard onto a portable token-based theme so it reads like an instrument panel: warm-paper background, hairline-ruled panels, IBM Plex type, monospace tabular numerics, and status carried by colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css as a thin token-driven view layer, and swaps the Bootstrap navbar and status badges for the design-system app bar and chips. Also includes pending API-key management, Galaxy hierarchy projection, and constraint-enforcement work with their tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DiscoverHierarchyOptions.cs | 24 + glauth.md | 260 ++++++++++ .../DashboardLdapLiveTests.cs | 52 ++ .../LiveLdapFactAttribute.cs | 20 + .../EffectiveLdapConfiguration.cs | 15 + .../Configuration/LdapOptions.cs | 28 ++ .../Dashboard/Components/App.razor | 1 + .../Components/Layout/DashboardLayout.razor | 77 ++- .../Components/Pages/ApiKeysPage.razor | 459 ++++++++++++++++++ .../Components/Shared/StatusBadge.razor | 15 +- .../Dashboard/DashboardApiKeyAuthorization.cs | 22 + .../DashboardApiKeyManagementRequest.cs | 9 + .../DashboardApiKeyManagementResult.cs | 17 + .../DashboardApiKeyManagementService.cs | 195 ++++++++ .../Dashboard/DashboardApiKeySummary.cs | 12 + .../DashboardConnectionStringDisplay.cs | 28 ++ ...DashboardEndpointRouteBuilderExtensions.cs | 10 +- .../IDashboardApiKeyManagementService.cs | 23 + .../Galaxy/GalaxyGlobMatcher.cs | 44 ++ .../Galaxy/GalaxyHierarchyIndex.cs | 106 ++++ .../Galaxy/GalaxyHierarchyProjector.cs | 246 ++++++++++ .../Galaxy/GalaxyHierarchyQueryResult.cs | 8 + .../Galaxy/GalaxyObjectView.cs | 8 + .../Galaxy/GalaxyTagLookup.cs | 8 + .../Properties/AssemblyInfo.cs | 4 + .../ApiKeyConstraintSerializer.cs | 28 ++ .../Authentication/ApiKeyConstraints.cs | 43 ++ .../Authorization/ConstraintEnforcer.cs | 165 +++++++ .../Authorization/ConstraintFailure.cs | 3 + .../Authorization/IConstraintEnforcer.cs | 33 ++ .../Sessions/SessionItemRegistration.cs | 6 + .../SessionLeaseMonitorHostedService.cs | 45 ++ .../wwwroot/css/dashboard.css | 373 +++++++++----- src/MxGateway.Server/wwwroot/css/theme.css | 379 +++++++++++++++ .../wwwroot/fonts/ibm-plex-mono-500.woff2 | Bin 0 -> 14988 bytes .../wwwroot/fonts/ibm-plex-sans-400.woff2 | Bin 0 -> 19156 bytes .../wwwroot/fonts/ibm-plex-sans-600.woff2 | Bin 0 -> 20356 bytes .../DashboardApiKeyAuthorizationTests.cs | 65 +++ .../DashboardApiKeyManagementServiceTests.cs | 237 +++++++++ .../DashboardConnectionStringDisplayTests.cs | 21 + .../Grpc/GalaxyRepositoryGrpcServiceTests.cs | 371 ++++++++++++++ .../Authorization/ConstraintEnforcerTests.cs | 247 ++++++++++ 42 files changed, 3529 insertions(+), 178 deletions(-) create mode 100644 clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs create mode 100644 glauth.md create mode 100644 src/MxGateway.IntegrationTests/DashboardLdapLiveTests.cs create mode 100644 src/MxGateway.IntegrationTests/LiveLdapFactAttribute.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/LdapOptions.cs create mode 100644 src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor create mode 100644 src/MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardApiKeyManagementRequest.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardApiKeyManagementResult.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs create mode 100644 src/MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyObjectView.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyTagLookup.cs create mode 100644 src/MxGateway.Server/Properties/AssemblyInfo.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs create mode 100644 src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs create mode 100644 src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs create mode 100644 src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs create mode 100644 src/MxGateway.Server/Sessions/SessionItemRegistration.cs create mode 100644 src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs create mode 100644 src/MxGateway.Server/wwwroot/css/theme.css create mode 100644 src/MxGateway.Server/wwwroot/fonts/ibm-plex-mono-500.woff2 create mode 100644 src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-400.woff2 create mode 100644 src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-600.woff2 create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs create mode 100644 src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs diff --git a/clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs b/clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs new file mode 100644 index 0000000..2ef067f --- /dev/null +++ b/clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs @@ -0,0 +1,24 @@ +namespace MxGateway.Client; + +public sealed record DiscoverHierarchyOptions +{ + public int? RootGobjectId { get; init; } + + public string? RootTagName { get; init; } + + public string? RootContainedPath { get; init; } + + public int? MaxDepth { get; init; } + + public IReadOnlyList CategoryIds { get; init; } = Array.Empty(); + + public IReadOnlyList TemplateChainContains { get; init; } = Array.Empty(); + + public string? TagNameGlob { get; init; } + + public bool? IncludeAttributes { get; init; } + + public bool AlarmBearingOnly { get; init; } + + public bool HistorizedOnly { get; init; } +} diff --git a/glauth.md b/glauth.md new file mode 100644 index 0000000..165ba42 --- /dev/null +++ b/glauth.md @@ -0,0 +1,260 @@ +# GLAuth — LDAP authn reference for mxaccessgw + +GLAuth is a lightweight LDAP server installed on this dev box at +`C:\publish\glauth\` and run as a Windows service via NSSM. It already +backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa +Admin UI's cookie login; this doc captures everything mxaccessgw needs +to consume the same directory so a single set of dev credentials covers +both stacks. + +The authoritative copy of LmxOpcUa's reference lives at +`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to +mxaccessgw — what users + groups are already provisioned, how to bind +against them, and what's needed to add a gw-specific role. + +## Connection details + +| Setting | Value | +|---|---| +| Protocol | LDAP (unencrypted) | +| Host | `localhost` | +| Port | `3893` | +| LDAPS | disabled in dev (set `[ldaps]` block to enable) | +| Base DN | `dc=lmxopcua,dc=local` | +| Bind DN format | `cn={username},dc=lmxopcua,dc=local` | +| Group OU | `ou=,ou=groups,dc=lmxopcua,dc=local` | +| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) | + +## Pre-existing groups (LmxOpcUa role taxonomy) + +These map cleanly onto MxAccess capability boundaries — mxaccessgw +should reuse them rather than define parallel groups so an operator with +LmxOpcUa write rights doesn't need a second account for the gw. + +| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping | +|---|---|---|---|---| +| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) | +| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) | +| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=lmxopcua,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) | +| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=lmxopcua,dc=local` | Write Configure attrs | `WriteSecured` (Configure) | +| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=lmxopcua,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added | + +**A user can be in multiple groups** — `othergroups = [...]` in the +config is a list. `admin` is the canonical example (in every role +group below). + +## Pre-provisioned users + +| Username | Password | UID | Primary group | Other groups | Capabilities | +|---|---|---|---|---|---| +| `readonly` | `readonly123` | 5001 | ReadOnly | — | Browse, read | +| `writeop` | `writeop123` | 5002 | WriteOperate | — | + plain Write | +| `writetune` | `writetune123` | 5005 | WriteTune | — | + WriteSecured (Tune) | +| `writeconfig` | `writeconfig123` | 5006 | WriteConfigure | — | + WriteSecured (Configure) | +| `alarmack` | `alarmack123` | 5003 | AlarmAck | — | Alarm acknowledgment | +| `admin` | `admin123` | 5004 | ReadOnly | WriteOperate, AlarmAck, WriteTune, WriteConfigure | All roles | +| `serviceaccount` | `serviceaccount123` | 5999 | ReadOnly | — | LDAP search capability (for bind-then-search) | + +For mxaccessgw dev, `admin` covers every gw-side capability test; +`readonly` is the right "negative" case for proving Browse-OK / +Write-denied. + +## Two bind patterns + +### 1. Direct bind (simplest) + +``` +DN: cn=admin,dc=lmxopcua,dc=local +Password: admin123 +``` + +Construct the DN from the username; bind. Works on GLAuth because +`backend.nameformat = "cn"` and `groupformat = "ou"` are set in the +config. **Doesn't translate to Active Directory** — AD users are keyed +by `sAMAccountName`, not `cn`. Use this only for dev convenience. + +### 2. Bind-then-search (production-grade) + +``` +1. Bind as the service account (cn=serviceaccount,dc=lmxopcua,dc=local + / serviceaccount123). +2. Search under dc=lmxopcua,dc=local with filter + (uid=) — or any attribute the deployment + identifies users by. GLAuth populates uid + cn. +3. Read the returned entry's DN + memberOf list (groups). +4. Bind again as the discovered DN with the entered password. If that + succeeds, authn passes; the memberOf values become the role set. +``` + +The second bind is the actual password check — the search is just a DN +discovery. This is the AD-friendly path: AD's +`tokenGroups` / `LDAP_MATCHING_RULE_IN_CHAIN` flatten nested groups, but +that's an enhancement, not required for first-pass dev. + +LmxOpcUa's `Server/Security/LdapUserAuthenticator.cs` ships a working +implementation of this pattern using `Novell.Directory.Ldap.NETStandard` +v3.6.0 — copy the bind-then-search loop from there if mxaccessgw wants +to avoid re-deriving the LDAP escape-string handling. + +## Suggested mxgw configuration shape + +A YAML/JSON section for mxaccessgw that mirrors LmxOpcUa's `LdapOptions` +record: + +```yaml +ldap: + enabled: true + server: localhost + port: 3893 + useTls: false + allowInsecureLdap: true # dev only + searchBase: "dc=lmxopcua,dc=local" + serviceAccountDn: "cn=serviceaccount,dc=lmxopcua,dc=local" + serviceAccountPassword: "serviceaccount123" + userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName + displayNameAttribute: "cn" + groupAttribute: "memberOf" + groupToRole: + ReadOnly: "Browse" + WriteOperate: "Write" + WriteTune: "WriteSecured" + WriteConfigure: "WriteSecured" + AlarmAck: "AlarmAck" +``` + +`groupAttribute` returns full DNs like +`ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` — the authenticator +should strip the leading `ou=` (or `cn=` against AD) RDN value and +look that up in `groupToRole`. + +## Adding a gw-specific group (when reuse isn't enough) + +If mxaccessgw needs a permission that doesn't fit the existing five +roles (e.g. `GwAdmin` for shutdown/recycle commands), add it to +GLAuth rather than running a separate LDAP server: + +1. Edit `C:\publish\glauth\glauth.cfg` +2. Append: + +```toml +[[groups]] + name = "GwAdmin" + gidnumber = 5510 # pick the next free GID +``` + +3. Add the group to whichever existing user(s) should have it via + `othergroups = [..., 5510]`. Or create a new user: + +```toml +[[users]] + name = "gwadmin" + givenname = "Gateway" + sn = "Admin" + mail = "gwadmin@lmxopcua.local" + uidnumber = 5010 + primarygroup = 5510 + passsha256 = "" +``` + +4. `nssm restart GLAuth` + +Generate `passsha256` from a plaintext password: + +```powershell +# Windows / PowerShell +$bytes = [System.Text.Encoding]::UTF8.GetBytes("yourpassword") +$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) +-join ($hash | ForEach-Object { $_.ToString("x2") }) +``` + +```bash +# WSL / git-bash +echo -n "yourpassword" | openssl dgst -sha256 +``` + +## Quick verification + +From mxaccessgw's dev box, prove the directory is reachable: + +```powershell +# Plain bind via PowerShell + System.DirectoryServices.Protocols +$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893") +$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic +$ldap.SessionOptions.ProtocolVersion = 3 +$ldap.SessionOptions.SecureSocketLayer = $false +$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=lmxopcua,dc=local","admin123") +$ldap.Bind($cred) +"Bind OK" +``` + +Or via `ldapsearch` if you have OpenLDAP CLI tools: + +```bash +ldapsearch -x -H ldap://localhost:3893 \ + -D "cn=admin,dc=lmxopcua,dc=local" -w admin123 \ + -b "dc=lmxopcua,dc=local" "(uid=admin)" +``` + +The response should list `admin`'s entry with `memberOf` populated for +all five role groups. + +## Service management + +```powershell +# Status / start / stop / restart +nssm status GLAuth +nssm start GLAuth +nssm stop GLAuth +nssm restart GLAuth + +# Inspect what NSSM was told to launch +nssm get GLAuth Parameters +``` + +Logs: + +| File | Purpose | +|---|---| +| `C:\publish\glauth\logs\stdout.log` | Bind events, search responses | +| `C:\publish\glauth\logs\stderr.log` | Startup errors, config parse failures | + +After editing `glauth.cfg`, always tail `stderr.log` after the restart +to catch a fat-fingered TOML before it bites at first bind: + +```powershell +nssm restart GLAuth +Get-Content C:\publish\glauth\logs\stderr.log -Tail 20 -Wait +``` + +## Active Directory migration cheat-sheet + +LmxOpcUa's `LdapOptions` xml-doc captures the AD overrides; same set +applies to mxaccessgw verbatim. Keys that change: + +| Field | GLAuth dev value | AD production value | +|---|---|---| +| `Server` | `localhost` | a domain controller FQDN, or the domain itself | +| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement | +| `UseTls` | `false` | `true` | +| `AllowInsecureLdap` | `true` | `false` | +| `SearchBase` | `dc=lmxopcua,dc=local` | `DC=corp,DC=example,DC=com` | +| `ServiceAccountDn` | `cn=serviceaccount,dc=lmxopcua,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` | +| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) | +| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) | + +`memberOf` returns full DNs; the authenticator strips the leading +`CN=` value and uses it as the lookup key in `groupToRole`. Nested +groups are **not** auto-expanded; either flatten in the directory or +add a `tokenGroups` query as an enhancement. + +## Security notes for production + +- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is + unencrypted on disk; anyone with read access to `C:\publish\glauth\` + can SHA256-rainbow-table the entries. Treat the dev creds as + throwaway. Production LDAP is Active Directory. +- The 3-fail / 10-minute lockout is per source IP, not per user — a + shared NAT can lock out a whole office. Tunable in `[behaviors]`. +- LDAPS isn't enabled in dev; binding sends passwords cleartext on the + wire. Fine for `localhost`, never expose port 3893 off-box without + enabling TLS first. diff --git a/src/MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/MxGateway.IntegrationTests/DashboardLdapLiveTests.cs new file mode 100644 index 0000000..a63e447 --- /dev/null +++ b/src/MxGateway.IntegrationTests/DashboardLdapLiveTests.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Dashboard; + +namespace MxGateway.IntegrationTests; + +public sealed class DashboardLdapLiveTests +{ + [LiveLdapFact] + [Trait("Category", "LiveLdap")] + public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() + { + DashboardAuthenticator authenticator = CreateAuthenticator(); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "admin", + "admin123", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + Assert.Equal("admin", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Contains(result.Principal.Claims, claim => + claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType + && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase)); + } + + [LiveLdapFact] + [Trait("Category", "LiveLdap")] + public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails() + { + DashboardAuthenticator authenticator = CreateAuthenticator(); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "readonly", + "readonly123", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal); + } + + private static DashboardAuthenticator CreateAuthenticator() + { + return new DashboardAuthenticator( + Options.Create(new GatewayOptions()), + NullLogger.Instance); + } +} diff --git a/src/MxGateway.IntegrationTests/LiveLdapFactAttribute.cs b/src/MxGateway.IntegrationTests/LiveLdapFactAttribute.cs new file mode 100644 index 0000000..a0b69a2 --- /dev/null +++ b/src/MxGateway.IntegrationTests/LiveLdapFactAttribute.cs @@ -0,0 +1,20 @@ +namespace MxGateway.IntegrationTests; + +public sealed class LiveLdapFactAttribute : FactAttribute +{ + public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_LDAP_TESTS"; + + public LiveLdapFactAttribute() + { + if (!Enabled) + { + Skip = $"Set {EnableVariableName}=1 to run live LDAP tests."; + } + } + + public static bool Enabled => + string.Equals( + Environment.GetEnvironmentVariable(EnableVariableName), + "1", + StringComparison.Ordinal); +} diff --git a/src/MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs new file mode 100644 index 0000000..824a7c8 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs @@ -0,0 +1,15 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveLdapConfiguration( + bool Enabled, + string Server, + int Port, + bool UseTls, + bool AllowInsecureLdap, + string SearchBase, + string ServiceAccountDn, + string ServiceAccountPassword, + string UserNameAttribute, + string DisplayNameAttribute, + string GroupAttribute, + string RequiredGroup); diff --git a/src/MxGateway.Server/Configuration/LdapOptions.cs b/src/MxGateway.Server/Configuration/LdapOptions.cs new file mode 100644 index 0000000..ac370dd --- /dev/null +++ b/src/MxGateway.Server/Configuration/LdapOptions.cs @@ -0,0 +1,28 @@ +namespace MxGateway.Server.Configuration; + +public sealed class LdapOptions +{ + public bool Enabled { get; init; } = true; + + public string Server { get; init; } = "localhost"; + + public int Port { get; init; } = 3893; + + public bool UseTls { get; init; } + + public bool AllowInsecureLdap { get; init; } = true; + + public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; + + public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local"; + + public string ServiceAccountPassword { get; init; } = "serviceaccount123"; + + public string UserNameAttribute { get; init; } = "cn"; + + public string DisplayNameAttribute { get; init; } = "cn"; + + public string GroupAttribute { get; init; } = "memberOf"; + + public string RequiredGroup { get; init; } = "GwAdmin"; +} diff --git a/src/MxGateway.Server/Dashboard/Components/App.razor b/src/MxGateway.Server/Dashboard/Components/App.razor index 4468c4c..09ec8c1 100644 --- a/src/MxGateway.Server/Dashboard/Components/App.razor +++ b/src/MxGateway.Server/Dashboard/Components/App.razor @@ -7,6 +7,7 @@ + diff --git a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor b/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor index 09dfa1e..3a3de0c 100644 --- a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor +++ b/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor @@ -2,55 +2,34 @@ @inject IOptions GatewayOptions
- -
+
+ MXAccess Gateway + + + + +
+ @authState.User.Identity?.Name +
+ + + +
+
+ + Sign in + +
+
+
@Body
diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor new file mode 100644 index 0000000..ca5fab6 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor @@ -0,0 +1,459 @@ +@page "/apikeys" +@page "/dashboard/apikeys" +@inherits DashboardPageBase +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject IDashboardApiKeyManagementService ApiKeyManagementService + +Dashboard API Keys + +@if (Snapshot is null) +{ +
Loading API keys.
+} +else +{ +
+
+

API Keys

+
@Snapshot.ApiKeys.Count key rows
+
+ @if (CanManageApiKeys) + { + + } +
+ + @if (CanManageApiKeys) + { + @if (!string.IsNullOrWhiteSpace(ResultMessage)) + { + + } + + @if (IsCreateDialogOpen) + { + + + } + } + +
+ @if (Snapshot.ApiKeys.Count == 0) + { +
No API keys are available for display.
+ } + else + { +
+ + + + + + + + + + + @if (CanManageApiKeys) + { + + } + + + + @foreach (DashboardApiKeySummary key in Snapshot.ApiKeys) + { + + + + + + + + + @if (CanManageApiKeys) + { + + } + + } + +
KeyStatusDisplay NameScopesConstraintsCreatedLast UsedActions
@key.KeyId@DashboardDisplay.Text(key.DisplayName)@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))@DashboardDisplay.Text(ConstraintText(key.Constraints))@DashboardDisplay.DateTime(key.CreatedUtc)@DashboardDisplay.DateTime(key.LastUsedUtc) +
+ + @if (key.RevokedUtc is null) + { + + } +
+
+
+ } +
+} + +@code { + private static readonly string[] AvailableScopes = + [ + GatewayScopes.SessionOpen, + GatewayScopes.SessionClose, + GatewayScopes.InvokeRead, + GatewayScopes.InvokeWrite, + GatewayScopes.InvokeSecure, + GatewayScopes.EventsRead, + GatewayScopes.MetadataRead, + GatewayScopes.Admin + ]; + + private ApiKeyCreateModel CreateModel { get; } = new(); + + private bool CanManageApiKeys { get; set; } + + private bool IsBusy { get; set; } + + private bool IsCreateDialogOpen { get; set; } + + private string? ResultMessage { get; set; } + + private bool LastOperationSucceeded { get; set; } + + private string? LastGeneratedApiKey { get; set; } + + protected override async Task OnInitializedAsync() + { + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User); + } + + private async Task CreateApiKeyAsync() + { + if (IsBusy) + { + return; + } + + if (!TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? validationMessage)) + { + SetResult(DashboardApiKeyManagementResult.Fail(validationMessage ?? "API key request is invalid.")); + return; + } + + await RunManagementActionAsync(user => ApiKeyManagementService.CreateAsync( + user, + request, + CancellationToken.None)) + .ConfigureAwait(false); + } + + private async Task RevokeApiKeyAsync(string keyId) + { + await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync( + user, + keyId, + CancellationToken.None)) + .ConfigureAwait(false); + } + + private async Task RotateApiKeyAsync(string keyId) + { + await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync( + user, + keyId, + CancellationToken.None)) + .ConfigureAwait(false); + } + + private async Task RunManagementActionAsync( + Func> action) + { + if (IsBusy) + { + return; + } + + IsBusy = true; + try + { + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User); + DashboardApiKeyManagementResult result = await action(authenticationState.User).ConfigureAwait(false); + SetResult(result); + if (result.Succeeded && result.ApiKey is not null) + { + CreateModel.Reset(); + IsCreateDialogOpen = false; + } + } + finally + { + IsBusy = false; + } + } + + private void SetResult(DashboardApiKeyManagementResult result) + { + LastOperationSucceeded = result.Succeeded; + ResultMessage = result.Message; + LastGeneratedApiKey = result.ApiKey; + } + + private void OpenCreateDialog() + { + IsCreateDialogOpen = true; + } + + private void CloseCreateDialog() + { + if (!IsBusy) + { + IsCreateDialogOpen = false; + } + } + + private bool TryBuildCreateRequest( + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request, + out string? validationMessage) + { + request = null; + validationMessage = null; + + if (!string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification) + && !int.TryParse( + CreateModel.MaxWriteClassification, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out int _)) + { + validationMessage = "Max write classification must be an integer."; + return false; + } + + int? maxWriteClassification = string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification) + ? null + : int.Parse( + CreateModel.MaxWriteClassification, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture); + + request = new DashboardApiKeyManagementRequest( + KeyId: CreateModel.KeyId, + DisplayName: CreateModel.DisplayName, + Scopes: CreateModel.SelectedScopes, + Constraints: new MxGateway.Server.Security.Authentication.ApiKeyConstraints( + ReadSubtrees: ParseList(CreateModel.ReadSubtrees), + WriteSubtrees: ParseList(CreateModel.WriteSubtrees), + ReadTagGlobs: ParseList(CreateModel.ReadTagGlobs), + WriteTagGlobs: ParseList(CreateModel.WriteTagGlobs), + MaxWriteClassification: maxWriteClassification, + BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees), + ReadAlarmOnly: CreateModel.ReadAlarmOnly, + ReadHistorizedOnly: CreateModel.ReadHistorizedOnly)); + + return true; + } + + private bool IsScopeSelected(string scope) + { + return CreateModel.SelectedScopes.Contains(scope); + } + + private void SetScope(string scope, ChangeEventArgs eventArgs) + { + bool selected = eventArgs.Value is bool value && value; + if (selected) + { + CreateModel.SelectedScopes.Add(scope); + } + else + { + CreateModel.SelectedScopes.Remove(scope); + } + } + + private static string ConstraintText(MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints) + { + if (constraints.IsEmpty) + { + return "unconstrained"; + } + + List parts = []; + AddList(parts, "read_subtrees", constraints.ReadSubtrees); + AddList(parts, "write_subtrees", constraints.WriteSubtrees); + AddList(parts, "read_tag_globs", constraints.ReadTagGlobs); + AddList(parts, "write_tag_globs", constraints.WriteTagGlobs); + AddList(parts, "browse_subtrees", constraints.BrowseSubtrees); + if (constraints.MaxWriteClassification is { } max) + { + parts.Add($"max_write_classification={max}"); + } + + if (constraints.ReadAlarmOnly) + { + parts.Add("read_alarm_only"); + } + + if (constraints.ReadHistorizedOnly) + { + parts.Add("read_historized_only"); + } + + return string.Join("; ", parts); + } + + private static void AddList(List parts, string name, IReadOnlyList values) + { + if (values.Count > 0) + { + parts.Add($"{name}=[{string.Join(", ", values)}]"); + } + } + + private static IReadOnlyList ParseList(string? value) + { + return (value ?? string.Empty) + .Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToArray(); + } + + private sealed class ApiKeyCreateModel + { + public string KeyId { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public HashSet SelectedScopes { get; } = new(StringComparer.Ordinal); + + public string ReadSubtrees { get; set; } = string.Empty; + + public string WriteSubtrees { get; set; } = string.Empty; + + public string ReadTagGlobs { get; set; } = string.Empty; + + public string WriteTagGlobs { get; set; } = string.Empty; + + public string BrowseSubtrees { get; set; } = string.Empty; + + public string MaxWriteClassification { get; set; } = string.Empty; + + public bool ReadAlarmOnly { get; set; } + + public bool ReadHistorizedOnly { get; set; } + + public void Reset() + { + KeyId = string.Empty; + DisplayName = string.Empty; + SelectedScopes.Clear(); + ReadSubtrees = string.Empty; + WriteSubtrees = string.Empty; + ReadTagGlobs = string.Empty; + WriteTagGlobs = string.Empty; + BrowseSubtrees = string.Empty; + MaxWriteClassification = string.Empty; + ReadAlarmOnly = false; + ReadHistorizedOnly = false; + } + } +} diff --git a/src/MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor b/src/MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor index 791227c..910851e 100644 --- a/src/MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor +++ b/src/MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor @@ -1,4 +1,4 @@ -@Text +@Text @code { [Parameter] @@ -6,12 +6,11 @@ private string CssClass => Text switch { - "Ready" or "Healthy" => "text-bg-success", - "Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info", - "Closed" => "text-bg-secondary", - "Stale" => "text-bg-warning", - "Faulted" or "Unavailable" => "text-bg-danger", - "Unknown" => "text-bg-light text-dark border", - _ => "text-bg-light text-dark border" + "Ready" or "Healthy" or "Active" => "chip-ok", + "Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn", + "Stale" or "Degraded" => "chip-warn", + "Faulted" or "Unavailable" => "chip-bad", + "Closed" or "Revoked" or "Unknown" => "chip-idle", + _ => "chip-idle" }; } diff --git a/src/MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs b/src/MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs new file mode 100644 index 0000000..d50bee8 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardApiKeyAuthorization(IOptions options) +{ + public bool CanManage(ClaimsPrincipal user) + { + if (user.Identity?.IsAuthenticated != true) + { + return false; + } + + string requiredGroup = options.Value.Ldap.RequiredGroup; + IEnumerable groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType) + .Select(claim => claim.Value); + + return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementRequest.cs b/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementRequest.cs new file mode 100644 index 0000000..21747b0 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementRequest.cs @@ -0,0 +1,9 @@ +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Server.Dashboard; + +public sealed record DashboardApiKeyManagementRequest( + string KeyId, + string DisplayName, + IReadOnlySet Scopes, + ApiKeyConstraints Constraints); diff --git a/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementResult.cs b/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementResult.cs new file mode 100644 index 0000000..b753b2c --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementResult.cs @@ -0,0 +1,17 @@ +namespace MxGateway.Server.Dashboard; + +public sealed record DashboardApiKeyManagementResult( + bool Succeeded, + string Message, + string? ApiKey) +{ + public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null) + { + return new DashboardApiKeyManagementResult(true, message, apiKey); + } + + public static DashboardApiKeyManagementResult Fail(string message) + { + return new DashboardApiKeyManagementResult(false, message, null); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs b/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs new file mode 100644 index 0000000..50758cf --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs @@ -0,0 +1,195 @@ +using System.Security.Claims; +using Microsoft.Data.Sqlite; +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardApiKeyManagementService( + DashboardApiKeyAuthorization authorization, + IApiKeyAdminStore adminStore, + IApiKeyAuditStore auditStore, + IApiKeySecretHasher hasher, + IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService +{ + private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys."; + + public bool CanManage(ClaimsPrincipal user) + { + return authorization.CanManage(user); + } + + public async Task CreateAsync( + ClaimsPrincipal user, + DashboardApiKeyManagementRequest request, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); + } + + string? validation = ValidateCreateRequest(request); + if (validation is not null) + { + return DashboardApiKeyManagementResult.Fail(validation); + } + + string keyId = request.KeyId.Trim(); + string secret = ApiKeySecretGenerator.Generate(); + string apiKey = FormatApiKey(keyId, secret); + + try + { + await adminStore.CreateAsync( + new ApiKeyCreateRequest( + KeyId: keyId, + KeyPrefix: $"mxgw_{keyId}", + SecretHash: hasher.HashSecret(secret), + DisplayName: request.DisplayName.Trim(), + Scopes: request.Scopes, + Constraints: request.Constraints, + CreatedUtc: DateTimeOffset.UtcNow), + cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false); + + return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey); + } + catch (ApiKeyPepperUnavailableException) + { + return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); + } + catch (SqliteException exception) when (exception.SqliteErrorCode == 19) + { + return DashboardApiKeyManagementResult.Fail("An API key with that id already exists."); + } + } + + public async Task RevokeAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); + } + + string? validation = ValidateKeyId(keyId); + if (validation is not null) + { + return DashboardApiKeyManagementResult.Fail(validation); + } + + string normalizedKeyId = keyId.Trim(); + bool revoked = await adminStore + .RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync( + normalizedKeyId, + "dashboard-revoke-key", + revoked ? "revoked" : "not-found-or-already-revoked", + cancellationToken) + .ConfigureAwait(false); + + return revoked + ? DashboardApiKeyManagementResult.Success("API key revoked.") + : DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked."); + } + + public async Task RotateAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); + } + + string? validation = ValidateKeyId(keyId); + if (validation is not null) + { + return DashboardApiKeyManagementResult.Fail(validation); + } + + string normalizedKeyId = keyId.Trim(); + string secret = ApiKeySecretGenerator.Generate(); + string apiKey = FormatApiKey(normalizedKeyId, secret); + + try + { + bool rotated = await adminStore + .RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken) + .ConfigureAwait(false); + + await AppendAuditAsync( + normalizedKeyId, + "dashboard-rotate-key", + rotated ? "rotated" : "not-found", + cancellationToken) + .ConfigureAwait(false); + + return rotated + ? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey) + : DashboardApiKeyManagementResult.Fail("API key was not found."); + } + catch (ApiKeyPepperUnavailableException) + { + return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); + } + } + + private async Task AppendAuditAsync( + string? keyId, + string eventType, + string? details, + CancellationToken cancellationToken) + { + await auditStore.AppendAsync( + new ApiKeyAuditEntry( + KeyId: keyId, + EventType: eventType, + RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(), + Details: details), + cancellationToken) + .ConfigureAwait(false); + } + + private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request) + { + string? keyIdValidation = ValidateKeyId(request.KeyId); + if (keyIdValidation is not null) + { + return keyIdValidation; + } + + if (string.IsNullOrWhiteSpace(request.DisplayName)) + { + return "Display name is required."; + } + + return null; + } + + private static string? ValidateKeyId(string keyId) + { + if (string.IsNullOrWhiteSpace(keyId)) + { + return "API key id is required."; + } + + return keyId.Trim().All(character => + char.IsAsciiLetterOrDigit(character) + || character is '.' or '-') + ? null + : "API key id may contain only letters, numbers, periods, and hyphens."; + } + + private static string FormatApiKey(string keyId, string secret) + { + return $"mxgw_{keyId}_{secret}"; + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs b/src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs new file mode 100644 index 0000000..4125b50 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs @@ -0,0 +1,12 @@ +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Server.Dashboard; + +public sealed record DashboardApiKeySummary( + string KeyId, + string DisplayName, + IReadOnlySet Scopes, + ApiKeyConstraints Constraints, + DateTimeOffset CreatedUtc, + DateTimeOffset? LastUsedUtc, + DateTimeOffset? RevokedUtc); diff --git a/src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs b/src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs new file mode 100644 index 0000000..64ce123 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs @@ -0,0 +1,28 @@ +using Microsoft.Data.SqlClient; + +namespace MxGateway.Server.Dashboard; + +public static class DashboardConnectionStringDisplay +{ + public static string GalaxyRepositoryConnectionString(string connectionString) + { + try + { + SqlConnectionStringBuilder builder = new(connectionString); + SqlConnectionStringBuilder display = new() + { + DataSource = builder.DataSource, + InitialCatalog = builder.InitialCatalog, + IntegratedSecurity = builder.IntegratedSecurity, + Encrypt = builder.Encrypt, + TrustServerCertificate = builder.TrustServerCertificate, + }; + + return display.ConnectionString; + } + catch (ArgumentException) + { + return "[invalid connection string]"; + } + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index 71fcce4..e9ff533 100644 --- a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -169,11 +169,17 @@ public static class DashboardEndpointRouteBuilderExtensions {HtmlEncoder.Default.Encode(title)} + -
-

{HtmlEncoder.Default.Encode(title)}

+
+ MXAccess Gateway +
+
+
+

{HtmlEncoder.Default.Encode(title)}

+
{body}
diff --git a/src/MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs b/src/MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs new file mode 100644 index 0000000..97329ed --- /dev/null +++ b/src/MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; + +namespace MxGateway.Server.Dashboard; + +public interface IDashboardApiKeyManagementService +{ + bool CanManage(ClaimsPrincipal user); + + Task CreateAsync( + ClaimsPrincipal user, + DashboardApiKeyManagementRequest request, + CancellationToken cancellationToken); + + Task RevokeAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken); + + Task RotateAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs b/src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs new file mode 100644 index 0000000..51a0955 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs @@ -0,0 +1,44 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace MxGateway.Server.Galaxy; + +public static class GalaxyGlobMatcher +{ + public static bool IsMatch(string value, string glob) + { + if (string.IsNullOrWhiteSpace(glob)) + { + return true; + } + + return Regex.IsMatch( + value ?? string.Empty, + BuildRegex(glob), + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + } + + private static string BuildRegex(string glob) + { + StringBuilder builder = new("^", glob.Length + 2); + foreach (char character in glob) + { + switch (character) + { + case '*': + builder.Append(".*"); + break; + case '?': + builder.Append('.'); + break; + default: + builder.Append(Regex.Escape(character.ToString())); + break; + } + } + + builder.Append('$'); + return builder.ToString(); + } +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs new file mode 100644 index 0000000..dc55a04 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs @@ -0,0 +1,106 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed class GalaxyHierarchyIndex +{ + private GalaxyHierarchyIndex( + IReadOnlyList objectViews, + IReadOnlyDictionary objectViewsById, + IReadOnlyDictionary tagsByAddress) + { + ObjectViews = objectViews; + ObjectViewsById = objectViewsById; + TagsByAddress = tagsByAddress; + } + + public static GalaxyHierarchyIndex Empty { get; } = new( + Array.Empty(), + new Dictionary(), + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public IReadOnlyList ObjectViews { get; } + + public IReadOnlyDictionary ObjectViewsById { get; } + + public IReadOnlyDictionary TagsByAddress { get; } + + public static GalaxyHierarchyIndex Build(IReadOnlyList objects) + { + if (objects.Count == 0) + { + return Empty; + } + + Dictionary objectsById = new(); + foreach (GalaxyObject obj in objects) + { + objectsById.TryAdd(obj.GobjectId, obj); + } + + List views = new(objects.Count); + Dictionary viewsById = new(); + Dictionary tagsByAddress = new(StringComparer.OrdinalIgnoreCase); + + foreach (GalaxyObject obj in objects) + { + string path = BuildContainedPath(obj, objectsById); + int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/'); + GalaxyObjectView view = new(obj, path, depth); + views.Add(view); + viewsById.TryAdd(obj.GobjectId, view); + + if (!string.IsNullOrWhiteSpace(obj.TagName)) + { + tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path)); + } + + foreach (GalaxyAttribute attribute in obj.Attributes) + { + if (!string.IsNullOrWhiteSpace(attribute.FullTagReference)) + { + tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path)); + } + } + } + + return new GalaxyHierarchyIndex( + views, + viewsById, + tagsByAddress); + } + + private static string BuildContainedPath( + GalaxyObject obj, + IReadOnlyDictionary objectsById) + { + Stack names = new(); + HashSet seen = []; + GalaxyObject? current = obj; + while (current is not null && seen.Add(current.GobjectId)) + { + names.Push(ResolvePathSegment(current)); + current = current.ParentGobjectId != 0 + && objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent) + ? parent + : null; + } + + return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name))); + } + + private static string ResolvePathSegment(GalaxyObject obj) + { + if (!string.IsNullOrWhiteSpace(obj.ContainedName)) + { + return obj.ContainedName; + } + + if (!string.IsNullOrWhiteSpace(obj.BrowseName)) + { + return obj.BrowseName; + } + + return obj.TagName; + } +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs new file mode 100644 index 0000000..3367082 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs @@ -0,0 +1,246 @@ +using System.Security.Cryptography; +using System.Text; +using Grpc.Core; +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public static class GalaxyHierarchyProjector +{ + public static GalaxyHierarchyQueryResult Project( + GalaxyHierarchyCacheEntry entry, + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs = null) + { + return Project( + entry, + request, + browseSubtreeGlobs, + offset: 0, + pageSize: int.MaxValue); + } + + public static GalaxyHierarchyQueryResult Project( + GalaxyHierarchyCacheEntry entry, + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs, + int offset, + int pageSize) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(request); + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero."); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero."); + } + + IReadOnlyList views = entry.Index.ObjectViews; + GalaxyObjectView? root = ResolveRoot(request, views); + int? maxDepth = request.MaxDepth; + if (maxDepth < 0) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "DiscoverHierarchy max_depth must be greater than or equal to zero when provided.")); + } + + List page = []; + int matchedCount = 0; + bool includeAttributes = IncludeAttributes(request); + foreach (GalaxyObjectView view in views) + { + if (!MatchesRoot(view, root, maxDepth) + || !MatchesBrowseSubtrees(view, browseSubtreeGlobs) + || !MatchesFilters(view.Object, request)) + { + continue; + } + + if (matchedCount >= offset && page.Count < pageSize) + { + page.Add(CloneObject(view.Object, includeAttributes)); + } + + matchedCount++; + } + + return new GalaxyHierarchyQueryResult( + page, + matchedCount, + ComputeFilterSignature(request, browseSubtreeGlobs)); + } + + public static GalaxyObject? FindObjectForTag( + GalaxyHierarchyCacheEntry entry, + string tagAddress) + { + if (string.IsNullOrWhiteSpace(tagAddress)) + { + return null; + } + + return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) + ? lookup.Object + : null; + } + + public static GalaxyAttribute? FindAttributeForTag( + GalaxyHierarchyCacheEntry entry, + string tagAddress) + { + if (string.IsNullOrWhiteSpace(tagAddress)) + { + return null; + } + + return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) + ? lookup.Attribute + : null; + } + + public static string GetContainedPath( + GalaxyHierarchyCacheEntry entry, + int gobjectId) + { + return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view) + ? view.ContainedPath + : string.Empty; + } + + private static GalaxyObjectView? ResolveRoot( + DiscoverHierarchyRequest request, + IReadOnlyList views) + { + GalaxyObjectView? root = request.RootCase switch + { + DiscoverHierarchyRequest.RootOneofCase.None => null, + DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault( + view => view.Object.GobjectId == request.RootGobjectId), + DiscoverHierarchyRequest.RootOneofCase.RootTagName => views.FirstOrDefault( + view => string.Equals(view.Object.TagName, request.RootTagName, StringComparison.OrdinalIgnoreCase)), + DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => views.FirstOrDefault( + view => string.Equals(view.ContainedPath, request.RootContainedPath, StringComparison.OrdinalIgnoreCase)), + _ => null, + }; + + if (request.RootCase != DiscoverHierarchyRequest.RootOneofCase.None && root is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "DiscoverHierarchy root was not found.")); + } + + return root; + } + + private static bool MatchesRoot( + GalaxyObjectView view, + GalaxyObjectView? root, + int? maxDepth) + { + if (root is null) + { + return true; + } + + bool isRoot = view.Object.GobjectId == root.Object.GobjectId; + bool isDescendant = view.ContainedPath.StartsWith(root.ContainedPath + "/", StringComparison.OrdinalIgnoreCase); + if (!isRoot && !isDescendant) + { + return false; + } + + return maxDepth is null || view.Depth - root.Depth <= maxDepth.Value; + } + + private static bool MatchesBrowseSubtrees( + GalaxyObjectView view, + IReadOnlyList? browseSubtreeGlobs) + { + return browseSubtreeGlobs is null + || browseSubtreeGlobs.Count == 0 + || browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob)); + } + + private static bool MatchesFilters( + GalaxyObject obj, + DiscoverHierarchyRequest request) + { + if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId)) + { + return false; + } + + foreach (string templateFilter in request.TemplateChainContains) + { + if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + + if (!string.IsNullOrWhiteSpace(request.TagNameGlob) + && !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob)) + { + return false; + } + + if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm)) + { + return false; + } + + if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized)) + { + return false; + } + + return true; + } + + private static bool IncludeAttributes(DiscoverHierarchyRequest request) + { + return !request.HasIncludeAttributes || request.IncludeAttributes; + } + + private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes) + { + GalaxyObject clone = source.Clone(); + if (!includeAttributes) + { + clone.Attributes.Clear(); + } + + return clone; + } + + public static string ComputeFilterSignature( + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs) + { + StringBuilder builder = new(); + builder.Append("root=").Append(request.RootCase).Append('|'); + builder.Append(request.RootCase switch + { + DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => request.RootGobjectId.ToString( + System.Globalization.CultureInfo.InvariantCulture), + DiscoverHierarchyRequest.RootOneofCase.RootTagName => request.RootTagName, + DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => request.RootContainedPath, + _ => string.Empty, + }); + builder.Append("|max=").Append(request.MaxDepth?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""); + builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order()); + builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase)); + builder.Append("|glob=").Append(request.TagNameGlob); + builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset"); + builder.Append("|alarm=").Append(request.AlarmBearingOnly); + builder.Append("|hist=").Append(request.HistorizedOnly); + builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty()).Order(StringComparer.OrdinalIgnoreCase)); + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexString(hash, 0, 12); + } +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs new file mode 100644 index 0000000..90f0c0a --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs @@ -0,0 +1,8 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed record GalaxyHierarchyQueryResult( + IReadOnlyList Objects, + int TotalObjectCount, + string FilterSignature); diff --git a/src/MxGateway.Server/Galaxy/GalaxyObjectView.cs b/src/MxGateway.Server/Galaxy/GalaxyObjectView.cs new file mode 100644 index 0000000..7989aa4 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyObjectView.cs @@ -0,0 +1,8 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed record GalaxyObjectView( + GalaxyObject Object, + string ContainedPath, + int Depth); diff --git a/src/MxGateway.Server/Galaxy/GalaxyTagLookup.cs b/src/MxGateway.Server/Galaxy/GalaxyTagLookup.cs new file mode 100644 index 0000000..449aec0 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyTagLookup.cs @@ -0,0 +1,8 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed record GalaxyTagLookup( + GalaxyObject Object, + GalaxyAttribute? Attribute, + string ContainedPath); diff --git a/src/MxGateway.Server/Properties/AssemblyInfo.cs b/src/MxGateway.Server/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..911b29f --- /dev/null +++ b/src/MxGateway.Server/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MxGateway.Tests")] +[assembly: InternalsVisibleTo("MxGateway.IntegrationTests")] diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs new file mode 100644 index 0000000..935e79a --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs @@ -0,0 +1,28 @@ +using System.Text.Json; + +namespace MxGateway.Server.Security.Authentication; + +public static class ApiKeyConstraintSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = false, + }; + + public static string? Serialize(ApiKeyConstraints constraints) + { + ArgumentNullException.ThrowIfNull(constraints); + return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions); + } + + public static ApiKeyConstraints Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return ApiKeyConstraints.Empty; + } + + return JsonSerializer.Deserialize(json, JsonOptions) ?? ApiKeyConstraints.Empty; + } +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs new file mode 100644 index 0000000..80acdae --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs @@ -0,0 +1,43 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyConstraints( + IReadOnlyList ReadSubtrees, + IReadOnlyList WriteSubtrees, + IReadOnlyList ReadTagGlobs, + IReadOnlyList WriteTagGlobs, + int? MaxWriteClassification, + IReadOnlyList BrowseSubtrees, + bool ReadAlarmOnly, + bool ReadHistorizedOnly) +{ + public static ApiKeyConstraints Empty { get; } = new( + ReadSubtrees: Array.Empty(), + WriteSubtrees: Array.Empty(), + ReadTagGlobs: Array.Empty(), + WriteTagGlobs: Array.Empty(), + MaxWriteClassification: null, + BrowseSubtrees: Array.Empty(), + ReadAlarmOnly: false, + ReadHistorizedOnly: false); + + public bool IsEmpty => + ReadSubtrees.Count == 0 + && WriteSubtrees.Count == 0 + && ReadTagGlobs.Count == 0 + && WriteTagGlobs.Count == 0 + && MaxWriteClassification is null + && BrowseSubtrees.Count == 0 + && !ReadAlarmOnly + && !ReadHistorizedOnly; + + public bool HasReadConstraints => + ReadSubtrees.Count > 0 + || ReadTagGlobs.Count > 0 + || ReadAlarmOnly + || ReadHistorizedOnly; + + public bool HasWriteConstraints => + WriteSubtrees.Count > 0 + || WriteTagGlobs.Count > 0 + || MaxWriteClassification is not null; +} diff --git a/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs new file mode 100644 index 0000000..11a44be --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -0,0 +1,165 @@ +using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Server.Galaxy; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Sessions; + +namespace MxGateway.Server.Security.Authorization; + +public sealed class ConstraintEnforcer( + IGalaxyHierarchyCache cache, + IApiKeyAuditStore auditStore) : IConstraintEnforcer +{ + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasReadConstraints) + { + return Task.FromResult(null); + } + + return Task.FromResult(CheckReadTarget(constraints, tagAddress)); + } + + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasReadConstraints) + { + return Task.FromResult(null); + } + + if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) + { + return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + } + + return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); + } + + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasWriteConstraints) + { + return Task.FromResult(null); + } + + if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) + { + return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + } + + GalaxyTagLookup? target = ResolveTarget(registration.TagAddress); + if (target is null) + { + return Task.FromResult(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.")); + } + + if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs)) + { + return Task.FromResult(new ConstraintFailure("write_scope", "Tag is outside the API key write scope.")); + } + + if (constraints.MaxWriteClassification is { } maxClassification) + { + GalaxyAttribute? attribute = target.Attribute; + if (attribute is null) + { + return Task.FromResult(new ConstraintFailure("max_write_classification", "Attribute security classification is not available.")); + } + + if (attribute.SecurityClassification > maxClassification) + { + return Task.FromResult(new ConstraintFailure( + "max_write_classification", + $"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}.")); + } + } + + return Task.FromResult(null); + } + + public async Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) + { + await auditStore.AppendAsync( + new ApiKeyAuditEntry( + KeyId: identity?.KeyId, + EventType: "constraint-denied", + RemoteAddress: null, + Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"), + cancellationToken) + .ConfigureAwait(false); + } + + private ConstraintFailure? CheckReadTarget( + ApiKeyConstraints constraints, + string tagAddress) + { + GalaxyTagLookup? target = ResolveTarget(tagAddress); + if (target is null) + { + return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."); + } + + if (!MatchesPathOrTag(target.ContainedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs)) + { + return new ConstraintFailure("read_scope", "Tag is outside the API key read scope."); + } + + if (constraints.ReadAlarmOnly && target.Attribute is not { IsAlarm: true }) + { + return new ConstraintFailure("read_alarm_only", "Tag is not an alarm-bearing attribute."); + } + + if (constraints.ReadHistorizedOnly && target.Attribute is not { IsHistorized: true }) + { + return new ConstraintFailure("read_historized_only", "Tag is not a historized attribute."); + } + + return null; + } + + private GalaxyTagLookup? ResolveTarget(string tagAddress) + { + GalaxyHierarchyCacheEntry entry = cache.Current; + return !string.IsNullOrWhiteSpace(tagAddress) + && entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) + ? lookup + : null; + } + + private static bool MatchesPathOrTag( + string containedPath, + string tagAddress, + IReadOnlyList subtreeGlobs, + IReadOnlyList tagGlobs) + { + bool hasSubtreeConstraint = subtreeGlobs.Count > 0; + bool hasTagConstraint = tagGlobs.Count > 0; + if (!hasSubtreeConstraint && !hasTagConstraint) + { + return true; + } + + return subtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(containedPath, glob)) + || tagGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(tagAddress, glob)); + } +} diff --git a/src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs b/src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs new file mode 100644 index 0000000..be5e614 --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs @@ -0,0 +1,3 @@ +namespace MxGateway.Server.Security.Authorization; + +public sealed record ConstraintFailure(string ConstraintName, string Message); diff --git a/src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs b/src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs new file mode 100644 index 0000000..c656634 --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs @@ -0,0 +1,33 @@ +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Sessions; + +namespace MxGateway.Server.Security.Authorization; + +public interface IConstraintEnforcer +{ + Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken); + + Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken); + + Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken); + + Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Sessions/SessionItemRegistration.cs b/src/MxGateway.Server/Sessions/SessionItemRegistration.cs new file mode 100644 index 0000000..83aad6b --- /dev/null +++ b/src/MxGateway.Server/Sessions/SessionItemRegistration.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Server.Sessions; + +public sealed record SessionItemRegistration( + int ServerHandle, + int ItemHandle, + string TagAddress); diff --git a/src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs b/src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs new file mode 100644 index 0000000..4537887 --- /dev/null +++ b/src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; + +namespace MxGateway.Server.Sessions; + +public sealed class SessionLeaseMonitorHostedService( + ISessionManager sessionManager, + IOptions options, + ILogger logger, + TimeProvider? timeProvider = null) : BackgroundService +{ + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.Sessions.LeaseSweepIntervalSeconds)); + using PeriodicTimer timer = new(interval, _timeProvider); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + await sessionManager + .CloseExpiredLeasesAsync(_timeProvider.GetUtcNow(), stoppingToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Session lease sweep failed."); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + } +} diff --git a/src/MxGateway.Server/wwwroot/css/dashboard.css b/src/MxGateway.Server/wwwroot/css/dashboard.css index 32375a5..ee57e3a 100644 --- a/src/MxGateway.Server/wwwroot/css/dashboard.css +++ b/src/MxGateway.Server/wwwroot/css/dashboard.css @@ -1,165 +1,300 @@ -:root { - --mxgw-surface: #f7f8fa; - --mxgw-border: #d8dee6; - --mxgw-ink-muted: #667085; - --mxgw-accent: #146c64; +/* ============================================================================ + MXAccess Gateway dashboard — view layer. + Layers over theme.css (the technical-light design system). Every colour, + font, and surface here resolves to a theme.css token — no hard-coded hex. + theme.css owns the tokens and the component library; this sheet only wires + the dashboard's own class names and Bootstrap widgets into that system. + ========================================================================= */ + +body.dashboard-body { min-height: 100vh; } + +/* ── App bar ───────────────────────────────────────────────────────────────── + theme.css styles .app-bar / .brand / .mark / .spacer. Here we centre the row + and add the inline nav and the signed-in-user cluster. */ +.app-bar { align-items: center; gap: 1.25rem; } +.app-bar .brand { color: var(--ink); } +.app-bar .brand:hover { text-decoration: none; } + +.app-nav { display: flex; flex-wrap: wrap; gap: 0.15rem; } +.app-nav a { + font-size: 0.82rem; + color: var(--ink-soft); + padding: 0.25rem 0.6rem; + border-radius: 4px; +} +.app-nav a:hover { color: var(--ink); background: #f0f0ec; text-decoration: none; } +.app-nav a.active { + color: var(--accent-deep); + background: #e7ecfb; + font-weight: 600; } -.dashboard-body { - background: var(--mxgw-surface); - color: #1f2933; -} - -.dashboard-navbar { - min-height: 3.5rem; -} - -.dashboard-content { - padding: 1.25rem; +.app-user { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.8rem; + color: var(--ink-soft); } +.app-user form { margin: 0; } +/* ── Page header ───────────────────────────────────────────────────────────── + h1 in sans, the sub-line in monospace as a quiet meta crumb. */ .dashboard-page-header { - align-items: center; - display: flex; - gap: 1rem; - justify-content: space-between; - margin-bottom: 1rem; -} - -.dashboard-page-header h1, -.section-heading h2 { - font-size: 1.35rem; - font-weight: 650; - letter-spacing: 0; - margin: 0; -} - -.section-heading { - margin-bottom: .75rem; -} - -.dashboard-section { - background: #fff; - border-top: 1px solid var(--mxgw-border); - margin-top: 1rem; - padding: 1rem 0 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + animation: rise 0.4s ease both; +} +.dashboard-page-header h1 { + font-size: 1.15rem; + font-weight: 600; + letter-spacing: 0.01em; + margin: 0; +} +.dashboard-page-header .text-secondary { + margin-top: 0.15rem; + font-family: var(--mono); + font-size: 0.78rem; + color: var(--ink-faint); } +/* ── KPI / metric cards ────────────────────────────────────────────────────── + The MetricCard component renders .metric-card with label/value/detail; this + is the technical-light aggregate card — uppercase eyebrow, big mono number. */ .metric-grid { - display: grid; - gap: .75rem; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); -} - -.metric-grid.compact { - grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + margin-bottom: 1rem; + animation: rise 0.4s ease both; + animation-delay: 0.04s; } +.metric-grid.compact { grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); } .metric-card { - border-color: var(--mxgw-border); - border-radius: .375rem; - box-shadow: none; + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + box-shadow: none; } - +.metric-card .card-body { padding: 0.7rem 0.9rem; } .metric-label { - color: var(--mxgw-ink-muted); - font-size: .78rem; - font-weight: 650; - letter-spacing: 0; - text-transform: uppercase; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); } - .metric-value { - color: var(--mxgw-accent); - font-size: 1.7rem; - font-weight: 700; - letter-spacing: 0; - line-height: 1.25; - overflow-wrap: anywhere; + margin-top: 0.25rem; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + color: var(--ink); + overflow-wrap: anywhere; } - .metric-detail { - color: var(--mxgw-ink-muted); - font-size: .85rem; - overflow-wrap: anywhere; + margin-top: 0.15rem; + font-size: 0.78rem; + color: var(--ink-faint); + overflow-wrap: anywhere; } +/* ── Section panels ────────────────────────────────────────────────────────── + Each .dashboard-section is a raised panel: white card, hairline border. */ +.dashboard-section { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + margin-top: 1rem; + padding: 0.9rem; + animation: rise 0.4s ease both; + animation-delay: 0.09s; +} + +.section-heading { margin-bottom: 0.6rem; } +.section-heading h2 { + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); + margin: 0; +} + +/* ── Data tables ───────────────────────────────────────────────────────────── + Dense, hairline-ruled, uppercase head on a faint fill. */ .dashboard-table { - --bs-table-bg: #fff; - border-color: var(--mxgw-border); - margin-bottom: 0; + width: 100%; + border-collapse: collapse; + margin-bottom: 0; + font-size: 0.85rem; + background: var(--card); } - -.dashboard-table th { - color: #344054; - font-weight: 650; - white-space: nowrap; -} - +.dashboard-table th, .dashboard-table td { - max-width: 24rem; - overflow-wrap: anywhere; + padding: 0.45rem 0.8rem; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid var(--rule); } +.dashboard-table th { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); + background: #fbfbf9; + white-space: nowrap; +} +.dashboard-table td { + max-width: 26rem; + overflow-wrap: anywhere; +} +.dashboard-table tbody tr:last-child td { border-bottom: none; } +.dashboard-table tbody tr:hover { background: #f3f6fd; } +/* Key/value detail tables: left label column, monospace values, zebra rows. */ .details-table th { - width: 14rem; + width: 16rem; + text-transform: none; + letter-spacing: 0; + font-size: 0.82rem; + font-weight: 500; + color: var(--ink-soft); + background: var(--card); +} +.details-table td { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-size: 0.82rem; + color: var(--ink); +} +.details-table tbody tr:nth-child(even) { background: #fbfbf9; } +.details-table tbody tr:hover { background: #fbfbf9; } + +/* Inline code: monospace, accent ink, no Bootstrap pink. */ +code { + font-family: var(--mono); + font-size: 0.82rem; + color: var(--accent-deep); } +/* ── Empty / placeholder state ───────────────────────────────────────────────*/ .empty-state { - background: #fff; - border: 1px dashed var(--mxgw-border); - border-radius: .375rem; - color: var(--mxgw-ink-muted); - padding: 1rem; + background: #fbfbf9; + border: 1px dashed var(--rule-strong); + border-radius: 6px; + color: var(--ink-faint); + padding: 1rem 1.1rem; + font-size: 0.85rem; } -.dashboard-login { - max-width: 28rem; +/* ── Buttons ───────────────────────────────────────────────────────────────── + Flatten Bootstrap buttons onto the single accent + hairline palette. */ +.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; } +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.btn-primary:hover, +.btn-primary:focus { + background: var(--accent-deep); + border-color: var(--accent-deep); + color: #fff; +} +.btn-outline-secondary { + color: var(--ink-soft); + background: var(--card); + border-color: var(--rule-strong); +} +.btn-outline-secondary:hover { + color: var(--ink); + background: #f0f0ec; + border-color: var(--rule-strong); +} +.btn-outline-danger { + color: var(--bad); + background: var(--card); + border-color: #eec3c3; +} +.btn-outline-danger:hover { + color: #fff; + background: var(--bad); + border-color: var(--bad); } +/* ── Forms ───────────────────────────────────────────────────────────────────*/ +.form-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); +} +.form-control, +.form-select { + font-size: 0.85rem; + border-color: var(--rule-strong); + border-radius: 5px; +} +.form-control:focus, +.form-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 0.15rem rgba(47, 95, 208, 0.15); +} + +/* ── Alerts ──────────────────────────────────────────────────────────────────*/ +.alert { border-radius: 6px; border-width: 1px; font-size: 0.85rem; } +.alert-success { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; } +.alert-danger { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; } + +/* ── Login ───────────────────────────────────────────────────────────────────*/ +.dashboard-login { max-width: 24rem; margin: 0 auto; } .login-card { - border-color: var(--mxgw-border); - border-radius: .375rem; + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; } +/* ── API key management ──────────────────────────────────────────────────────*/ .api-key-management-grid { - display: grid; - gap: .75rem; - grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); } - .scope-grid { - display: grid; - gap: .35rem .75rem; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + display: grid; + gap: 0.35rem 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); } - .one-time-secret { - display: block; - overflow-wrap: anywhere; - white-space: normal; + display: block; + overflow-wrap: anywhere; + white-space: normal; + font-family: var(--mono); } - -.api-key-create-modal { - display: block; -} - +.api-key-create-modal { display: block; } .api-key-create-modal .modal-body { - max-height: min(70vh, 44rem); - overflow-y: auto; + max-height: min(70vh, 44rem); + overflow-y: auto; +} +.modal-content { + border: 1px solid var(--rule-strong); + border-radius: 8px; } @media (max-width: 700px) { - .dashboard-content { - padding: .75rem; - } - - .dashboard-page-header { - align-items: flex-start; - flex-direction: column; - } - - .details-table th { - width: 9rem; - } + .page { padding: 0.85rem; } + .dashboard-page-header { + flex-direction: column; + align-items: flex-start; + } + .details-table th { width: 9rem; } } diff --git a/src/MxGateway.Server/wwwroot/css/theme.css b/src/MxGateway.Server/wwwroot/css/theme.css new file mode 100644 index 0000000..0d587ce --- /dev/null +++ b/src/MxGateway.Server/wwwroot/css/theme.css @@ -0,0 +1,379 @@ +/* ============================================================================ + Technical-Light design system — portable theme layer + ---------------------------------------------------------------------------- + A refined technical-light aesthetic: warm-neutral paper, hairline rules, + IBM Plex type, monospace tabular numerics, status carried by colour. Built + to layer over Bootstrap 5 via --bs-* overrides, but every rule below works + standalone — Bootstrap is optional. + + HOW TO ADOPT + 1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the + @font-face url() paths below to wherever you serve them. + 2. Include this file once, globally. Add view-specific rules in a separate + stylesheet — never edit the token block per-view. + 3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.* + helpers; do not hand-pick hex values in feature CSS. + ========================================================================= */ + +/* ── Vendored fonts (embedded woff2, no network/CDN fetch) ─────────────────── + Adjust these url()s to your asset route. If you cannot vendor the fonts the + --sans / --mono fallback stacks below degrade gracefully to system fonts. */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; font-weight: 400; font-display: swap; + src: url('/fonts/ibm-plex-sans-400.woff2') format('woff2'); +} +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; font-weight: 600; font-display: swap; + src: url('/fonts/ibm-plex-sans-600.woff2') format('woff2'); +} +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; font-weight: 500; font-display: swap; + src: url('/fonts/ibm-plex-mono-500.woff2') format('woff2'); +} + +/* ── Design tokens ─────────────────────────────────────────────────────────── + The single source of truth. Re-theme by editing only this block. */ +:root { + /* Surfaces & ink */ + --paper: #f4f4f1; /* page background — warm off-white, never pure */ + --card: #ffffff; /* raised surfaces: cards, bars, table heads */ + --ink: #1b1d21; /* primary text */ + --ink-soft: #5a6066; /* secondary text, labels */ + --ink-faint: #8b9097; /* tertiary text, captions, units */ + --rule: #e4e4df; /* hairline borders / row dividers */ + --rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */ + + /* Accent */ + --accent: #2f5fd0; /* links, sort arrows, primary actions */ + --accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */ + + /* Status — foreground */ + --ok: #2f9e44; + --warn: #e8920c; + --bad: #e03131; + --idle: #868e96; + + /* Status — tinted backgrounds (pair with the matching foreground) */ + --ok-bg: #e9f6ec; + --warn-bg: #fdf1dd; + --bad-bg: #fceaea; + --idle-bg: #eef0f2; + + /* Type stacks — Plex first, graceful system fallback */ + --mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + --sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + + /* Bootstrap 5 overrides — harmless if Bootstrap is absent */ + --bs-body-bg: var(--paper); + --bs-body-color: var(--ink); + --bs-body-font-family: var(--sans); + --bs-body-font-size: 0.9rem; + --bs-primary: var(--accent); + --bs-border-color: var(--rule); + --bs-emphasis-color: var(--ink); +} + +/* ── Base ──────────────────────────────────────────────────────────────────── + The faint top-right radial is the one deliberate flourish — a soft sheen, + not a gradient wash. Keep it subtle. */ +body { + background: + radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%), + var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: 0.9rem; + -webkit-font-smoothing: antialiased; +} + +/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */ +.numeric, +.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-deep); text-decoration: underline; } + +/* ── App chrome: top bar ───────────────────────────────────────────────────── + One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta + text and any status pill pushed hard right. */ +.app-bar { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 0.85rem 1.25rem; + background: var(--card); + border-bottom: 1px solid var(--rule-strong); +} +.app-bar .brand { + font-weight: 600; + font-size: 1.05rem; + letter-spacing: 0.02em; +} +.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */ +.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; } +.app-bar .spacer { flex: 1; } /* pushes meta/pill right */ +.app-bar .meta { + font-family: var(--mono); + font-size: 0.78rem; + color: var(--ink-soft); +} + +/* ── Connection / liveness pill ────────────────────────────────────────────── + A rounded pill with a dot, driven entirely by data-state. Use for any + live-link health indicator (websocket, SSE, polling). */ +.conn-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--rule-strong); + color: var(--ink-soft); + background: var(--card); +} +.conn-pill .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--idle); +} +.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); } +.conn-pill[data-state="connected"] .dot { background: var(--ok); } +.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); } +.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; } +.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); } +.conn-pill[data-state="disconnected"] .dot { background: var(--bad); } + +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } } + +/* ── Status text helpers ───────────────────────────────────────────────────── + Recolour a value in place — counts, ratios, error totals. */ +.s-ok { color: var(--ok); } +.s-warn { color: var(--warn); } +.s-bad { color: var(--bad); } +.s-idle { color: var(--idle); } + +/* ── State chip ────────────────────────────────────────────────────────────── + Compact rectangular badge for an enumerated state (bound/recovering/…). + Squarer than the pill; use the pill for liveness, the chip for state. */ +.chip { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.5rem; + border-radius: 4px; + border: 1px solid transparent; +} +.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; } +.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; } +.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; } +.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); } + +/* ── Panel — the base raised surface ───────────────────────────────────────── + A white card with a hairline border and 8px radius. .panel-head is the + uppercase eyebrow label that sits on top. */ +.panel { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; +} +.panel-head { + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--rule); +} + +/* ── Page wrapper ──────────────────────────────────────────────────────────── + Centred, capped width, even gutter. */ +.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; } + +/* ── Reveal-on-paint ───────────────────────────────────────────────────────── + Add .rise to top-level sections; stagger with inline animation-delay + (.02s, .08s, .14s …) so panels settle in sequence, not all at once. */ +@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.rise { animation: rise 0.4s ease both; } + +/* ════════════════════════════════════════════════════════════════════════════ + COMPONENT LIBRARY + Generic, reusable pieces. View-specific layout belongs in a separate sheet. + ════════════════════════════════════════════════════════════════════════════ */ + +/* ── KPI / aggregate cards ─────────────────────────────────────────────────── + A responsive strip of headline numbers. .agg-card.alert / .caution tint the + whole card when a watched metric goes non-zero. */ +.agg-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.75rem; + margin-bottom: 1rem; +} +@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } } + +.agg-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + padding: 0.7rem 0.9rem; +} +.agg-label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); +} +.agg-value { + margin-top: 0.25rem; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + display: flex; + align-items: baseline; + gap: 0.35rem; +} +.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */ + font-size: 0.85rem; + font-weight: 400; + color: var(--ink-faint); +} +.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); } +.agg-card.alert .agg-value { color: var(--bad); } +.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); } +.agg-card.caution .agg-value { color: #b56a00; } + +/* ── Metric card + key/value rows ──────────────────────────────────────────── + A .panel-head over a stack of .kv rows: label left, monospace value right. + Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 0.85rem; + margin-bottom: 1rem; +} +.metric-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + overflow: hidden; +} +.metric-card .panel-head { margin: 0; } + +.kv { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + padding: 0.32rem 0.9rem; + font-size: 0.85rem; +} +.kv:nth-child(even) { background: #fbfbf9; } +.kv .k { color: var(--ink-soft); } +.kv .v { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + text-align: right; +} +.kv .v.warn { color: var(--warn); } +.kv .v.bad { color: var(--bad); } +.kv .v.ok { color: var(--ok); } + +/* ── Toolbar ───────────────────────────────────────────────────────────────── + Filter/search row that sits inside a .panel above a table. */ +.toolbar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--rule); +} +.toolbar .spacer { flex: 1; } +.tb-search { max-width: 280px; } +.tb-state { max-width: 150px; } +.tb-check { + display: flex; align-items: center; gap: 0.35rem; + font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; + user-select: none; +} +.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); } + +/* ── Data table ────────────────────────────────────────────────────────────── + Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric + columns get .num (right-aligned, monospace). Rows are clickable by default — + drop the cursor/hover rules if yours are not. */ +.table-wrap { overflow-x: auto; } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.data-table th, +.data-table td { + padding: 0.45rem 0.8rem; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--rule); +} +.data-table th { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); + background: #fbfbf9; + position: sticky; + top: 0; +} +.data-table th.num, +.data-table td.num { text-align: right; font-family: var(--mono); } + +.data-table th.sortable { cursor: pointer; user-select: none; } +.data-table th.sortable:hover { color: var(--ink); } +.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); } +.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); } + +.data-table tbody tr { cursor: pointer; transition: background 0.08s; } +.data-table tbody tr:hover { background: #f3f6fd; } +.data-table tbody tr:last-child td { border-bottom: none; } + +.empty-row { + text-align: center !important; + color: var(--ink-faint); + padding: 1.6rem !important; + font-style: italic; +} + +/* ── Direction / category tag ──────────────────────────────────────────────── + Tiny inline tag for a per-row category (e.g. read vs write). */ +.dir-tag { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.1rem 0.4rem; + border-radius: 3px; +} +.dir-read { color: var(--accent-deep); background: #e7ecfb; } +.dir-write { color: #8a5a00; background: var(--warn-bg); } + +/* ── Inline notice ─────────────────────────────────────────────────────────── + A .panel with a warning tint — for "this thing is gone / degraded" banners. */ +.notice { + padding: 0.85rem 1.1rem; + margin-bottom: 1rem; + color: #b56a00; + background: var(--warn-bg); + border-color: #efd6a6; +} diff --git a/src/MxGateway.Server/wwwroot/fonts/ibm-plex-mono-500.woff2 b/src/MxGateway.Server/wwwroot/fonts/ibm-plex-mono-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..99c261038d1394955e815031abc536f4c298c119 GIT binary patch literal 14988 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

75CNIK|LQ=k&v}yD zdlU0E^!LvB6#_TLy4jnk4XFgKe=kicDz~DJHvKl$5qC%6iPH z>#L(areX~d?u#@Vj0)VWpu(@EBfz-61rLM-+2M(9l78Nyb z%kT_%8-5emX zj%~^#XJ->)w3`BR^fwReZTII(pGPlwf&8Y{Tnn&!i;y$oH^y2Q)k2C*bx|`y?B1FB zX|YIgtA32ND|u(I|!V7}i}LN6%;llpp7%>4s&nors$RHI1M%qPBo9B+c!hFi9pgFF0JxHbJNM ze3B^U=(%#WPRftv8-+R`R9HF1TtGTA?P`&)Xo~mNi>m1Sp9!+0PqA$pmV{x(PR83g zT!QCkDp8tggpQy8DMqn>pT`TFN~4#>%Cs6~W)3%yfSNef%YaDyWQCqktS~bsW!L6< zcFgR{x9Odkfg^>>&yP?L7yz~nXUpOeJxZCMD9lyrw2F8IvXzP?CHemi3M7szym^QI z7H^vd*9u~=OMNR92ljiiUeia)QiMy8s#Kb zi%cU;J9W9*PTd73QC^Z`hBl=fG66xk100UZk6Yl*&K;tU8#$xhSscC8Ax-rWD--7K z%EKthahxYx_tiGpsyACl%0jCW^30w+-(kPA59?B)dYXu#cVorGyzlNCt#%hY$lpzR+OF z#)D9L9IFhA#+I?Nq=<4*>I--({6#A1$EU@Q#d}FIrYZbx&ioPSZrGPzewc=1-*fS( z^Kdcu=8vShaO=xknEl(~U)9-AvN6 zkk4DW{^o$gKCXTDYM6Vo?3k%IV+JEd$5yA1eHHWUgMwnkkgRoV?$;e@5 zyKLC33Y!#MIL9I&(9e@ywgLvz$JCxACs(bCATNG`nC)%@=hQ9@BxVI_*Rqn7Fl0W%w&8i>niwp^d z%sgUx$GegHTVwjDD@PYBd3<5ag)P$!KJ~^er@NOHCUu-E{W?d$=Oe09p;`?=PGiyJ zguyGI8kIMnp1Za%cOE@&1%x+|5On`saB|G5;JDVvqxZ~SWwbU2es>6(!U0^7Ac8UwBum091jr&kJZ4d6itRw1! zotSWq=wP#>bWKJwGu9UPBL0%Xr6(z>8Fn|b-C|~TFzSiIM945(a!h6qIUcfHP0Hm+ zIV(!&u&11r)QWPgzOdV!7dG=%>=+es6$DKhNmis*ld+7gN)lMhiO_Q?YD@}7ot74* z6p<;a>CoYbq@j3LKI|a24F|>9*n_a+M_(ed*N11A zRjf6ul`GPey)#ow5DAF$7!nRkolAGyatl2kqP9t>RZd+(6VsMZ2WBC9Aw`M)l~GbC zB>fT>g{(l9KT>d{H=zUi8c8#^c}Y^| z$qrXna$}ooa_UKcN3Wl65=cqF(W{(|mKZGRNbB!xc~j#4>?6+{SI1Ar67*|rLx<*> z%a#4c*vy*zn(i^shrnX=PH$AK{DB) zFRoe)Kq8O>(z64mO zdXGt}%EFJJ?c5pao$3j60We4XU zj;lP&$(}ErvgWfhllsB+mBAq;f7H}O#Hp-wl|Qm8Y&rCv|9>o?pkA4o`7Wg@rhK)L z!!-1V`P02{YmwL>*la*;qg87jbE3j6Hi5BHFHI*aMNK_AU>{t>)0s0Tg0M`8V+igVh zpFQ~)Vc zYT>mz_=>=+eECw35M#=m@xLcvxkT~El~TPLa>z}{9beFF=miyXL)Dc zTIz+Mt6aHtd4I^upTJdPC7NMDvgqeVhD?un^QY5NO*}3}3o#`-7e0@>mXA+R*9@)L zy6DkhcMV>h!EH!6_SkPj$M%h%>(E&H*yo3>AYLCU$3&Bt?L>{qxd-3QX3Dci=iG~L zc#MIhX|A_H#gBKiDf&k}f)%)E_=M3Ly1QfUgp^}q67NYEA~08k>%y(6rOee=H+fpP zytc4_#xOi>>Z=JAA$q7{Lb3Dk@e<*fa#+WON8$M8iWF(SE!Iid@`aIDK4JW3qrCJ* zkol-p>f-q2{yrl@-{GB)DVhBT{PxYvuO5FZZ%K`)`X z*XY05!ckdD2_$=@iBcCkjHqSCt$RTJ>>NDxX#NK>|GjE2qX1&@kea;01YKmb=S zY^b1z4XQk+BXR}<=WvGV1;n0oReBFGTQk9zv@<@Ab1>cJS?UHyjQ>4kH;yVw3h*czpf!C^By-+pfg#Rj&$5iT@XM;*+RZzpcIZwHp z=@{%SBna^g1=~<|DPi=gWJPQ(cqNv^ywBn2G-VlKh1Mt~)o_p2l&UlA=Ee4L4P?eF z7FVy@GW<9)9(f$wOPRNDOp{76;=6B6qD4UuD>Qr>vGWmVH4jHQE0vz5;CQP*k9vu; zt5Eq7^w#9q_^FCD(Q(y z^Pf+6NFXl_wQBA`qJtQ?O8oH)iJN@%$K5-) z`ppyML~}Oba0+DuHZ6khMqMlv%jniqCiZ7=>LMW!)I4FpP`1>Sn`*6*T=Wl>G>bkn zg|Gc)!V8L^C{yxbFD5YrGNs?XRjMawzPM_};^OYcAY_-4ig2)^8M43eZw3Dke~drD z{|kRL8&5M5{||r6L53jVT4top>Y4asj#;qGa40kb%OXv&XX1}RMRvmO2U%L0de7jG zIn-fk7HxdYM1o@$-7Q4|-{m`e#n-?_Xtte$3IwP%F2$DUf(~EBn>5Tf;hXSGn#I;w z7W+mTzIB;v_{LeU`oSh+zbgHVeSdo);~eCLFuPJkd5-MgH{`zA!anG8HO;dR*IYvy z@lnVmo&8#$>uK1>Yi=OT+x!If>DjNH{YIZ#={}yZYX9oru)pn?9mj;9{RhTv*MLBQ zf4AO;wJCM&0~p(WYX&?to@2>#qkg8V`TyS|rQm(eC39srbfAW1u zzds!Rid!iRMb%keGN-=>h(L&{vmCyEmwCBwMSFO#DyY5P5!fy3dSUNpeU!Qj>m#yq zdy!-v4Q>BT+Jo2LK|G68_R%r`4m{_Ej%Citw-ZlK?klxC@1 zR)}YuPK5nee5{yv5|@U$ER$z_$WfL4_i0GW;N=29&XOUxz`zhelakx(lC^5RAi7Iw z^6UQ08p~BVgeqHfG+1e72@`6Gb1_vZK| z*@cpKh3qpmgo<~jmJDW4M=2fqW~1ML275mE%(A3dbrB-y1u}XKGJ8#u4LLPPfWW~9 z-9;?+Jh1C)ZpntJXP8>PK1Y{{fPJVTwMjXle?fU~P5}jM>$d}NZPy6TetS+mFRe5G zltLoeKx-P1m+(pCDadI+(hOFxYXWgn_2%gUboFu^I}AM24S~W!d*LKNaFYOXPwP#- zp9})!CY%7Qeg&q$KK>?%-lTl<+=JZZ+!?`NZ0^R1!%*AtYeO#(};UOfvZYU!AxoxFB(+wL72H#6EXc&jBFDjxZwdC_n z!iF~KXn5xVSY(+%Gry`?l*W&=UDp>7J~jrP&O%)S2F!FSIQLz|prq7Yv4l+nH90w!7%3CxuFqMo1k`UeRsF1 z3~M=9UY6Ip0f6@B2XSV1a|4-KPWz;^x>QfUx~bchn)X$X3~r5dTp$GB%4E?ij?tbD zg!R?r?Ht`%gb^T6HpmVIt>~GHCe2&jV)L;&k=dFk`XL-utO#|yRHmi)8;Qxmmx2QQtp6OPA+!+%gl=(ybi%_reVo8N`|}+q&>Pp zFaqr-q87lKbB+KC`UgTMljLfkG%BFsVp~Jz*~~OKU`$dnyat^=0l{zuVy429BRqb3 z4SvD2fmEYJ(kk8dZGzuK4$RUpBLT3l8e2*RxW%4bqzcObxpOI-(#YsI3pfzVZ6*tq zA;8c?uYVOJ{8?sfGWI*O!3Ah2s}f53N?N%RwahU|s-7k*T^&0N@5Yj*BHW7!ZG()? zMKCmrqO{A-6p_F-bll9m0_PUtu;GYNgq%o_Y>;!9IM*7M400Rm4C5wz&X`~g1Z1pf0TwXSdKorDLKaeMW#&c5 z;yooegeVlaIEv1WUD?zVA3BWOyP-C??=a5sFIC>8%{Oi@MB>~8WEAUeGx$&}QSnNj zEl}&EHw2|%il#&mtl^yn3nETCa9FkPu_QF851^- zXQ<^4XMyE)7zbmE?0^<2G4fN}CbnpVQPN*cMzh&>u1ka3SvNB3tJ){H+Cqc+1VBnc zZ9m6sy<3F{O=XJA#CQalZiQBnhlw1LJ zwv~^nzZv+C9OC+`{5&T_?_-5022*Fxl|qGqv1!~VIJmBjd8U0JkJxE5%G@jW`ALj8 z7O5w)(mD2=Ms_#O*B#*53)@&)HkNn(?=n>ayP^o8H7pO6g8Q3x9roJ4CkLhh?ru$E zCe4*w=v(OlVZhFIW&@i}e%dC7L)d^lrcTGU;hJFTY6%xuNoIwFJ&K}b5Bj+PnFa+8 zL}fC{xKBPt-1Xuh9h!0#x z3@H)dRrB(^#j5#L4!npPIB`n{$14WvcDAt9&vsaZhO*V{FY4@eXzGr-D{=rGYz2J0 zN1bP5neOM-IIa8u%2I`HaKQsH#_l4VO4q%)CD1I#)IeO6^F8Kq_`~~qz^JgQRY0kCb~w^z+RgwEtu;0S0I9LaxhSZt`&Fw*l^TABd%` zi4`7ZAqp#0{V;4D9=pz)*79Wg5Vpc;?cMFzcY1jzs(G&G%D3-b{Y<*oZ8`u%yxdj$ zX2h%#4&UuZpXSxnwZs>=xkgvOT$}#-+Zee}^$~-f%!r=0Zc+k=ihSGQ2CL3Wh#t zy?_JRu22V%X&-eC4`Rv~I{hAKREPEr0N@M^T%CzuidrX!quI+>Cue)NPRJ92nNTAkvs}?LhMsXm33Ta4wK=xV??Zp8=XllNfKhoGq6By0wmUKk9c? zvVm9aQ$c_aQR|+8R?2r&9?Jy==r>EXZ`A;${0NG@%Mo-1bRJ*^SUv`Sz?zA0Uz_2c zYlVzYbhsK98y0n<)WINNJ0?48>fcnfIxNo-{Q-PLv3wR~Xm5U=@AmYc3pHF%<{hOJ zGdkUmSZxLqSuT-bO5^9ownBz=D^$FftqMK(Ck&x<%h6d>3Pz!;7=M0gTr|FGU`nX^ zBlL(ScMiTZKF4>tL4tHntD2?->&3!if*vp_K)3L#EVacd9aICgjCo9_%kA2ep-%t3 z%^HZO^Rw{GtRu|eX#g}h*<%*ll=A#A^WihdlwT*?# zDzwG5ncyfX*MO>H3$3f#fA8M!fyr*WE$=#EM6In`rDU3}OwS-4*s!p&HS8GMdc+#j zz`*!R_BP{(R=@?^DNIMjtte1@iD?DXEeAroaUYi}K<&m_qA0!lT-zT!apo?omCtGL=O0L1fXKJ156(IN%z4_Cw=3)+AKkrY<*hy6jwGh)9ae z(gPtE%!0j0n}6C$unmkCZIil?xii_EXZCy;kJZrBCD5Lv?bdanMEX>r9192sN$1e| zj;Z(_Wv}tL z>1kUrp0+QD3sAkf>QB9HbTJ^fTlhKjG=4^yFn`qiszG595fx1gJFu}4*hWA>GNeJ2 z@|z3i?h**?J@=kcwz-+@jqtFBO%P%jqn3j~3oX;a0%LUK7G2hnV_Wn2Bk&wmNCfF# z&{;$}fQWMq8sr+Vm)CEyuFIvw22bD;Z(Lz71HY9>bnwjh`qI$meQ^eo&M80`{*MJR z`S98B{n2&(?B62r01~ypIn+7S=zR-Kp)5iE$ffBIi}wrSN;POsb@ovp5q^W=8Cbpz z#{?6zt=8`~bNg_on0gNIY?kyw(vaFN7!gr;t8V_AOO&MzY%=+sWLT=N4}`gs^}zg*r?jFn}OHjEo94 zBrE0VcPra4T-$R9P7cK;_^QI|=rPWVwfhDrmZunW7(W>}49UcBF4$s#&H}xVTz2H!|QaZ_Dd^_C@9-K1Och-!L5pz6;w)<$`lfyLO$8`?{=W+O(goAxtum61wW?Y6 z;XBYC{4M{1X6C`A@wjPz6_$bLtv#!VxWI}`{G}LgRcpmXg(2hhg68-?m&VduKR^I> z6RF)?VZ0;F`Wjc}36{1v;dW5tH5@5ML|%MY0Y(VcG_RaoEt>uRH;HeT7}3=z<);D6XuU~7Ou-Lad80VOiTauxT`I zk1uGUq9&y&JhVX?1Oy~uG|#@aRN3)NR@mP5OO2U#Jl5*ssj+y2hJb*ms^&g1w=3#F z7qYWU$m26j;k-%X;v^bH{Xe1t1eD|Rr77$46}-O-Hu)>y?bzJ`ynpAt{n!0v>*^H) z!(jjccYJ{Pl{)rNd;RN>*}XEoUO+`0UkVOvFe%4|781Bnf&hE~G{Xgf+W2bbYI+|m zK*2zoT1+@0hC@9PnJ}u|A68UDxMFO|E2IHsbwD8*3CiMt0X`_eK? zfr7yxgRv^ds=ZJ*a6wntP{0H!*r4lRGf5i5-6U{tDj@{m>L*aL(!ph$Y-H2U1&Vlx zZBjo^KRb;5GL%F>!?jKaO>ki$=3^@U!7{AE|8%*D9{i{4)Jd)6{IDU?+Asq1FbVI` zt)obvcH{f~tR84$LOGIf2JKjggN-;w94cxUffvpJ-~rT4ACT$!AHD)41TX>={4pE? zhRKCPJrlqQcZ;PE3)_~$$Xl@#&Q)nVQN9#|x`d@jCY)t?sQ}kh&x@7wWS6W;mP!T6 zRU50VD!G{Ns4|uE*bxa8EkYmxv0^#u36v>S#)d$SYz1nRkjYW$i%Y6g9NBV8sh2mU zbT5Sg5;+fDie;+wOXsVUSSuw5Wk$J%<3jR|Bkn*=dk)-C0qhL9GCW&blmNwwQEXCW z$TiNjyqx0A#1I(9AOzFTs*$~rOBYo&w&uYlJ2!${e}!0w$LJ7VIT%_NC7g* z`(%dC_MjLrbWEQW4(bjQdN9paph&SorAjPO=B;v-s#Lh8TCF-YVL`nHO`0`Ys>N0V zK5EsjL!0;ZIpY-CMp-tQDm!d=BgbiHopas=7hRI;vMcgjGtFjOd~n?j`EGh=jxWCY z_Gp%|Y{&J!j>mV5NGy?R$+YDPW$#t$KJ-yXS5M!-(8$=tmZ_P!MOWN4`A^Ml4+1Q^ z8DwqSj$M299XNF4*oj+Co!QU~n-lJbNU$gG@0BA&r>V{$)Q(yJW5niy1ZR@{7k9=> z%Z8YUqZ4F#qkHDdf?j?-9)CTNZ=IVu$|vJuh3ogS7csp2U^~&D%Vy5bkgdhn(~-U6 z+vdpLPMy^fp1&puLvjW~P$UA@0tTbV1(FCD0@lTGIY5jkOD(h93M;L$+CporZ5`{@ zQ9}`VC&b1WyF(zc)JlsitgF9@$s^37Ka1gRFPO&2sQ!f@Z-|~+{bAPkxsF*GM*nn& z=M)H6Kbi1EaP;*L^u%2$|Qm2?^p}z9zy=|}T Wm+jZ3c#rX>OWE+pg-nQ{F3fEmM~x%^ literal 0 HcmV?d00001 diff --git a/src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-400.woff2 b/src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..93bcd6430c8ac016e88aa259017395fef162b673 GIT binary patch literal 19156 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%0DDx4q@qn(zDT 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 literal 0 HcmV?d00001 diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs new file mode 100644 index 0000000..9d784ce --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs @@ -0,0 +1,65 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Dashboard; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardApiKeyAuthorizationTests +{ + [Fact] + public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = CreatePrincipal("GwAdmin"); + + Assert.True(authorization.CanManage(user)); + } + + [Fact] + public void CanManage_AuthenticatedUserWithRequiredGroupDnClaim_ReturnsTrue() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = CreatePrincipal("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"); + + Assert.True(authorization.CanManage(user)); + } + + [Fact] + public void CanManage_AnonymousUser_ReturnsFalse() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = new(new ClaimsIdentity()); + + Assert.False(authorization.CanManage(user)); + } + + [Fact] + public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse() + { + DashboardApiKeyAuthorization authorization = CreateAuthorization(); + ClaimsPrincipal user = CreatePrincipal("ReadOnly"); + + Assert.False(authorization.CanManage(user)); + } + + private static DashboardApiKeyAuthorization CreateAuthorization() + { + return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions + { + Ldap = new LdapOptions + { + RequiredGroup = "GwAdmin", + }, + })); + } + + private static ClaimsPrincipal CreatePrincipal(string group) + { + ClaimsIdentity identity = new( + [new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)], + DashboardAuthenticationDefaults.AuthenticationScheme); + + return new ClaimsPrincipal(identity); + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs new file mode 100644 index 0000000..818b481 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs @@ -0,0 +1,237 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardApiKeyManagementServiceTests +{ + [Fact] + public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore() + { + FakeApiKeyAdminStore adminStore = new(); + DashboardApiKeyManagementService service = CreateService(adminStore); + + DashboardApiKeyManagementResult result = await service.CreateAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + CreateRequest(), + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, adminStore.CreateCount); + } + + [Fact] + public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits() + { + FakeApiKeyAdminStore adminStore = new(); + FakeApiKeyAuditStore auditStore = new(); + FakeApiKeySecretHasher hasher = new(); + DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher); + + DashboardApiKeyManagementResult result = await service.CreateAsync( + CreateAuthorizedUser(), + CreateRequest(), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.ApiKey); + Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal); + string secret = result.ApiKey["mxgw_operator01_".Length..]; + Assert.Equal(secret, hasher.LastSecret); + Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal); + ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests); + Assert.Equal("operator01", stored.KeyId); + Assert.Equal("Operator", stored.DisplayName); + Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes); + Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees); + Assert.Contains(auditStore.Entries, entry => + entry.EventType == "dashboard-create-key" + && entry.KeyId == "operator01"); + } + + [Fact] + public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore() + { + FakeApiKeyAdminStore adminStore = new(); + DashboardApiKeyManagementService service = CreateService(adminStore); + + DashboardApiKeyManagementResult result = await service.RevokeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + "operator01", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, adminStore.RevokeCount); + } + + [Fact] + public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits() + { + FakeApiKeyAdminStore adminStore = new() { RevokeResult = true }; + FakeApiKeyAuditStore auditStore = new(); + DashboardApiKeyManagementService service = CreateService(adminStore, auditStore); + + DashboardApiKeyManagementResult result = await service.RevokeAsync( + CreateAuthorizedUser(), + "operator01", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal("operator01", adminStore.LastRevokedKeyId); + Assert.Contains(auditStore.Entries, entry => + entry.EventType == "dashboard-revoke-key" + && entry.KeyId == "operator01" + && entry.Details == "revoked"); + } + + [Fact] + public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits() + { + FakeApiKeyAdminStore adminStore = new() { RotateResult = true }; + FakeApiKeyAuditStore auditStore = new(); + FakeApiKeySecretHasher hasher = new(); + DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher); + + DashboardApiKeyManagementResult result = await service.RotateAsync( + CreateAuthorizedUser(), + "operator01", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.ApiKey); + Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal); + Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash); + Assert.Contains(auditStore.Entries, entry => + entry.EventType == "dashboard-rotate-key" + && entry.KeyId == "operator01" + && entry.Details == "rotated"); + } + + private static DashboardApiKeyManagementService CreateService( + FakeApiKeyAdminStore? adminStore = null, + FakeApiKeyAuditStore? auditStore = null, + FakeApiKeySecretHasher? hasher = null) + { + GatewayOptions options = new() + { + Ldap = new LdapOptions + { + RequiredGroup = "GwAdmin", + }, + }; + + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + + return new DashboardApiKeyManagementService( + new DashboardApiKeyAuthorization(Options.Create(options)), + adminStore ?? new FakeApiKeyAdminStore(), + auditStore ?? new FakeApiKeyAuditStore(), + hasher ?? new FakeApiKeySecretHasher(), + new HttpContextAccessor { HttpContext = httpContext }); + } + + private static DashboardApiKeyManagementRequest CreateRequest() + { + return new DashboardApiKeyManagementRequest( + KeyId: "operator01", + DisplayName: "Operator", + Scopes: new HashSet([GatewayScopes.SessionOpen], StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty with + { + BrowseSubtrees = ["Area1/*"], + }); + } + + private static ClaimsPrincipal CreateAuthorizedUser() + { + ClaimsIdentity identity = new( + [new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")], + DashboardAuthenticationDefaults.AuthenticationScheme); + + return new ClaimsPrincipal(identity); + } + + private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore + { + public int CreateCount { get; private set; } + + public int RevokeCount { get; private set; } + + public bool RevokeResult { get; init; } + + public bool RotateResult { get; init; } + + public string? LastRevokedKeyId { get; private set; } + + public byte[]? LastRotatedSecretHash { get; private set; } + + public List CreatedRequests { get; } = []; + + public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) + { + CreateCount++; + CreatedRequests.Add(request); + return Task.CompletedTask; + } + + public Task> ListAsync(CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + + public Task RevokeAsync( + string keyId, + DateTimeOffset revokedUtc, + CancellationToken cancellationToken) + { + RevokeCount++; + LastRevokedKeyId = keyId; + return Task.FromResult(RevokeResult); + } + + public Task RotateAsync( + string keyId, + byte[] secretHash, + DateTimeOffset rotatedUtc, + CancellationToken cancellationToken) + { + LastRotatedSecretHash = secretHash; + return Task.FromResult(RotateResult); + } + } + + private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore + { + public List Entries { get; } = []; + + public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) + { + Entries.Add(entry); + return Task.CompletedTask; + } + + public Task> ListRecentAsync( + int count, + CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + } + + private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher + { + public string? LastSecret { get; private set; } + + public byte[] HashSecret(string secret) + { + LastSecret = secret; + return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}"); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs new file mode 100644 index 0000000..930e281 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs @@ -0,0 +1,21 @@ +using MxGateway.Server.Dashboard; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardConnectionStringDisplayTests +{ + [Fact] + public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields() + { + string display = DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString( + "Server=localhost;Database=ZB;User ID=mxuser;Password=secret;Encrypt=True;Trust Server Certificate=False;"); + + Assert.Contains("Data Source=localhost", display, StringComparison.Ordinal); + Assert.Contains("Initial Catalog=ZB", display, StringComparison.Ordinal); + Assert.Contains("Encrypt=True", display, StringComparison.Ordinal); + Assert.DoesNotContain("User", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Password", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("secret", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("mxuser", display, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs new file mode 100644 index 0000000..4388d9d --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs @@ -0,0 +1,371 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Galaxy; +using MxGateway.Server.Grpc; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Gateway.Grpc; + +public sealed class GalaxyRepositoryGrpcServiceTests +{ + [Fact] + public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + }, + new TestServerCallContext()); + + Assert.Equal(2, reply.Objects.Count); + Assert.Equal("Object_001", reply.Objects[0].TagName); + Assert.Equal("Object_002", reply.Objects[1].TagName); + Assert.StartsWith("7:", reply.NextPageToken, StringComparison.Ordinal); + Assert.EndsWith(":2", reply.NextPageToken, StringComparison.Ordinal); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + DiscoverHierarchyReply firstPage = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + }, + new TestServerCallContext()); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + PageToken = firstPage.NextPageToken, + }, + new TestServerCallContext()); + + GalaxyObject item = Assert.Single(reply.Objects); + Assert.Equal("Object_003", item.TagName); + Assert.Equal("", reply.NextPageToken); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Theory] + [InlineData("-1", 1)] + [InlineData("not-an-offset", 1)] + [InlineData("7:4", 1)] + [InlineData("6:2", 1)] + [InlineData("", -1)] + public async Task DiscoverHierarchy_WithInvalidPagingArguments_ReturnsInvalidArgument( + string pageToken, + int pageSize) + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = pageSize, + PageToken = pageToken, + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + } + + [Fact] + public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootContainedPath = "Area1/Line3", + MaxDepth = 1, + PageSize = 10, + }, + new TestServerCallContext()); + + Assert.Equal(["Line3", "Pump_001", "Valve_001"], reply.Objects.Select(obj => obj.TagName)); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootTagName = "Area1", + TagNameGlob = "Pump_*", + AlarmBearingOnly = true, + HistorizedOnly = true, + IncludeAttributes = false, + PageSize = 10, + CategoryIds = { 10 }, + TemplateChainContains = { "Pump" }, + }, + new TestServerCallContext()); + + GalaxyObject obj = Assert.Single(reply.Objects); + Assert.Equal("Pump_001", obj.TagName); + Assert.Empty(obj.Attributes); + Assert.Equal(1, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply first = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootGobjectId = 1, + PageSize = 1, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + DiscoverHierarchyReply second = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootGobjectId = 1, + PageSize = 1, + PageToken = first.NextPageToken, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + GalaxyObject firstObject = Assert.Single(first.Objects); + GalaxyObject secondObject = Assert.Single(second.Objects); + Assert.Equal(2, first.TotalObjectCount); + Assert.Equal(2, second.TotalObjectCount); + Assert.NotEqual(firstObject.TagName, secondObject.TagName); + } + + [Fact] + public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + DiscoverHierarchyReply first = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 1, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 1, + PageToken = first.NextPageToken, + CategoryIds = { 11 }, + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootTagName = "Missing", + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.NotFound, exception.StatusCode); + } + + private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry) + { + GalaxyRepositoryOptions options = new() + { + ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", + }; + return new GalaxyRepositoryGrpcService( + new global::MxGateway.Server.Galaxy.GalaxyRepository(options), + new StubGalaxyHierarchyCache(entry), + new GalaxyDeployNotifier(), + new GatewayRequestIdentityAccessor(), + NullLogger.Instance); + } + + private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) + { + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Sequence = 7, + LastSuccessAt = DateTimeOffset.UtcNow, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown with + { + Status = DashboardGalaxyStatus.Healthy, + ObjectCount = objects.Count, + }, + ObjectCount = objects.Count, + }; + } + + private static IReadOnlyList CreateObjects(int count) + { + return Enumerable.Range(1, count) + .Select(index => new GalaxyObject + { + GobjectId = index, + TagName = $"Object_{index:000}", + BrowseName = $"Object_{index:000}", + }) + .ToArray(); + } + + private static IReadOnlyList CreateFilterObjects() + { + return + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + BrowseName = "Area1", + IsArea = true, + CategoryId = 13, + }, + new GalaxyObject + { + GobjectId = 2, + TagName = "Line3", + ContainedName = "Line3", + BrowseName = "Line3", + ParentGobjectId = 1, + CategoryId = 10, + TemplateChain = { "$Line", "$Base" }, + }, + new GalaxyObject + { + GobjectId = 3, + TagName = "Pump_001", + ContainedName = "Pump", + BrowseName = "Pump_001", + ParentGobjectId = 2, + CategoryId = 10, + TemplateChain = { "$Pump", "$Base" }, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Pump_001.PV", + IsAlarm = true, + IsHistorized = true, + SecurityClassification = 2, + }, + }, + }, + new GalaxyObject + { + GobjectId = 4, + TagName = "Valve_001", + ContainedName = "Valve", + BrowseName = "Valve_001", + ParentGobjectId = 2, + CategoryId = 11, + TemplateChain = { "$Valve" }, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Valve_001.PV", + }, + }, + }, + new GalaxyObject + { + GobjectId = 5, + TagName = "Other_001", + ContainedName = "Other", + BrowseName = "Other_001", + CategoryId = 10, + }, + ]; + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache + { + public GalaxyHierarchyCacheEntry Current { get; } = current; + + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext + { + private readonly Metadata requestHeaders = []; + private readonly Metadata responseTrailers = []; + private readonly Dictionary userState = []; + private Status status; + private WriteOptions? writeOptions; + + protected override string MethodCore => "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"; + + protected override string HostCore => "localhost"; + + protected override string PeerCore => "ipv4:127.0.0.1:5000"; + + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + + protected override Metadata RequestHeadersCore => requestHeaders; + + protected override CancellationToken CancellationTokenCore => cancellationToken; + + protected override Metadata ResponseTrailersCore => responseTrailers; + + protected override Status StatusCore + { + get => status; + set => status = value; + } + + protected override WriteOptions? WriteOptionsCore + { + get => writeOptions; + set => writeOptions = value; + } + + protected override AuthContext AuthContextCore { get; } = new( + string.Empty, + new Dictionary>(StringComparer.Ordinal)); + + protected override IDictionary UserStateCore => userState; + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs new file mode 100644 index 0000000..fadc1b4 --- /dev/null +++ b/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -0,0 +1,247 @@ +using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Galaxy; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; +using MxGateway.Server.Sessions; + +namespace MxGateway.Tests.Security.Authorization; + +public sealed class ConstraintEnforcerTests +{ + [Fact] + public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadSubtrees = ["Area1/*"], + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Other_001.PV", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_scope", failure.ConstraintName); + } + + [Fact] + public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() + { + ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + WriteSubtrees = ["Area1/*"], + MaxWriteClassification = 1, + }); + GatewaySession session = CreateSession(); + session.TrackCommandReply( + new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = 12, + ItemDefinition = "Pump_001.PV", + }, + }, + new MxCommandReply + { + ProtocolStatus = MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(), + AddItem = new AddItemReply { ItemHandle = 42 }, + }); + + ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync( + identity, + session, + serverHandle: 12, + itemHandle: 42, + CancellationToken.None); + Assert.NotNull(failure); + + await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None); + + ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries); + Assert.Equal("operator01", entry.KeyId); + Assert.Equal("constraint-denied", entry.EventType); + Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); + } + + [Fact] + public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadHistorizedOnly = true, + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Pump_001.NonHistorized", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_historized_only", failure.ConstraintName); + } + + [Fact] + public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadAlarmOnly = true, + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Pump_001.PV", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_alarm_only", failure.ConstraintName); + } + + [Fact] + public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadHistorizedOnly = true, + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Pump_001", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_historized_only", failure.ConstraintName); + } + + private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore) + { + auditStore = new FakeAuditStore(); + return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore); + } + + private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints) + { + return new ApiKeyIdentity( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: constraints); + } + + private static GatewaySession CreateSession() + { + GatewaySession session = new( + "session-1", + "mxaccess", + "pipe", + "nonce", + "operator", + "client", + "correlation", + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + DateTimeOffset.UtcNow); + return session; + } + + private static GalaxyHierarchyCacheEntry CreateEntry() + { + IReadOnlyList objects = + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + }, + new GalaxyObject + { + GobjectId = 2, + TagName = "Pump_001", + ContainedName = "Pump", + ParentGobjectId = 1, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Pump_001.PV", + SecurityClassification = 2, + IsHistorized = true, + }, + new GalaxyAttribute + { + AttributeName = "Alarm", + FullTagReference = "Pump_001.Alarm", + IsAlarm = true, + }, + new GalaxyAttribute + { + AttributeName = "NonHistorized", + FullTagReference = "Pump_001.NonHistorized", + }, + }, + }, + new GalaxyObject + { + GobjectId = 3, + TagName = "Other_001", + ContainedName = "Other", + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Other_001.PV", + }, + }, + }, + ]; + + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown, + }; + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache + { + public GalaxyHierarchyCacheEntry Current { get; } = current; + + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class FakeAuditStore : IApiKeyAuditStore + { + public List Entries { get; } = []; + + public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) + { + Entries.Add(entry); + return Task.CompletedTask; + } + + public Task> ListRecentAsync(int count, CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + } +}