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 0000000..99c2610 Binary files /dev/null and b/src/MxGateway.Server/wwwroot/fonts/ibm-plex-mono-500.woff2 differ 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 0000000..93bcd64 Binary files /dev/null and b/src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-400.woff2 differ diff --git a/src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-600.woff2 b/src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-600.woff2 new file mode 100644 index 0000000..0ac91d6 Binary files /dev/null and b/src/MxGateway.Server/wwwroot/fonts/ibm-plex-sans-600.woff2 differ 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>([]); + } + } +}