Compare commits

...

7 Commits

Author SHA1 Message Date
Joseph Doherty e00ee61cf0 Place Last Refresh next to Last Deploy on the Galaxy page
Group the two double-width timestamp cards at the start of the metric
row so the deploy/refresh pair reads together, ahead of the count cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:20:30 -04:00
Joseph Doherty 271bf7edff Give Galaxy timestamp cards double-width boxes
The Last Deploy and Last Refresh metric cards hold full timestamps that
wrapped to three or four lines in a single-width card. Add a Wide option
to MetricCard (grid-column: span 2) and set it on both Galaxy timestamp
cards. Also switch .metric-value to overflow-wrap: break-word so a date
token is never split mid-value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:16:56 -04:00
Joseph Doherty 3397e99783 Document the dashboard API Keys management page
The dashboard's API Keys page (list plus Create/Rotate/Revoke and the
create dialog) had no design-doc coverage even though Authorization.md
already documents the constraint model it exposes. Add an "API keys
page" section to GatewayDashboardDesign.md describing the table columns,
the LDAP-group-gated management actions, the one-time secret reveal, and
audit logging. Cross-link it from the constraint-enforcement section of
Authorization.md and the CLI section of Authentication.md so the two
key-management surfaces reference each other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:07:35 -04:00
Joseph Doherty f598b3a647 Stop dashboard table cells breaking whole words
overflow-wrap: anywhere let the table layout shrink a column below its
longest word, splitting tokens like "unconstrained" mid-word. Switch to
break-word so a word only breaks when it genuinely cannot fit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:56:55 -04:00
Joseph Doherty 509b0118d4 Keep dashboard button labels on a single line
Bootstrap 5 .btn no longer pins white-space, so the Rotate/Revoke action
buttons broke mid-word in the narrow API Keys actions column. Pin .btn
to white-space: nowrap so a label always reads as one word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:54:53 -04:00
Joseph Doherty 298836d2f3 Keep dashboard status chips on a single line
Status chips wrapped to two lines in narrow table columns (e.g. the
session State column). Pin .chip to white-space: nowrap so an enumerated
state always reads as one token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:19:36 -04:00
Joseph Doherty 96bea1d478 Apply technical-light design system to the gateway dashboard
Restyles the Blazor dashboard onto a portable token-based theme so it
reads like an instrument panel: warm-paper background, hairline-ruled
panels, IBM Plex type, monospace tabular numerics, and status carried by
colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css
as a thin token-driven view layer, and swaps the Bootstrap navbar and
status badges for the design-system app bar and chips.

Also includes pending API-key management, Galaxy hierarchy projection,
and constraint-enforcement work with their tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:31:04 -04:00
47 changed files with 3606 additions and 180 deletions
@@ -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<int> CategoryIds { get; init; } = Array.Empty<int>();
public IReadOnlyList<string> TemplateChainContains { get; init; } = Array.Empty<string>();
public string? TagNameGlob { get; init; }
public bool? IncludeAttributes { get; init; }
public bool AlarmBearingOnly { get; init; }
public bool HistorizedOnly { get; init; }
}
+5
View File
@@ -223,6 +223,10 @@ constraints remain fully unconstrained after migration.
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling.
The CLI is not the only management surface: the dashboard API Keys page
creates, rotates, and revokes keys through the same `IApiKeyAdminStore`. See
[Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page).
## Scope Serialization
Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic:
@@ -276,4 +280,5 @@ Singletons are safe because each operation opens its own short-lived `SqliteConn
- [Gateway Configuration](./GatewayConfiguration.md)
- [Authorization](./Authorization.md)
- [Gateway Dashboard Design](./GatewayDashboardDesign.md)
- [Diagnostics](./Diagnostics.md)
+7
View File
@@ -161,6 +161,12 @@ Glob matching is anchored, case-insensitive, and supports `*` and `?`.
Subtree and tag glob lists are alternatives: matching either list allows that
scope dimension. Empty lists mean unconstrained for that dimension.
Constraints are set when a key is created — through the `apikey create-key`
flags (see [Authentication](./Authentication.md)) or the dashboard API Keys
page create dialog (see
[Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page)). The
dashboard API Keys page also renders each key's effective constraints.
The service checks read constraints for `AddItem`, `AddItem2`, `AddItemBulk`,
`SubscribeBulk`, and `AdviseItemBulk`. It checks write constraints for
`Write`, `Write2`, `WriteSecured`, and `WriteSecured2`. Successful item
@@ -252,6 +258,7 @@ Singleton lifetimes are appropriate because none of the three classes hold per-r
## Related Documentation
- [Authentication](./Authentication.md)
- [Gateway Dashboard Design](./GatewayDashboardDesign.md)
- [Grpc](./Grpc.md)
- [GatewayConfiguration](./GatewayConfiguration.md)
- [Galaxy Repository Browse](./GalaxyRepository.md)
+51
View File
@@ -49,6 +49,7 @@ Endpoint layout:
/dashboard/workers
/dashboard/events
/dashboard/galaxy
/dashboard/apikeys
/dashboard/settings
/dashboard/_blazor
```
@@ -83,6 +84,7 @@ MxGateway.Server
SessionDetailsPage.razor
WorkersPage.razor
EventsPage.razor
ApiKeysPage.razor
SettingsPage.razor
Shared/
MetricCard.razor
@@ -91,6 +93,9 @@ MxGateway.Server
DashboardSnapshotService.cs
DashboardAuthorizationHandler.cs
DashboardAuthenticator.cs
DashboardApiKeyAuthorization.cs
DashboardApiKeyManagementService.cs
DashboardApiKeySummary.cs
DashboardSnapshot.cs
DashboardSessionSummary.cs
DashboardWorkerSummary.cs
@@ -249,6 +254,52 @@ Show aggregate event diagnostics:
Do not display full tag values by default. If value display is later added, make
it opt-in and redacted.
### API keys page
`/dashboard/apikeys` lists the gateway's API keys and, for authorized
operators, manages them. It reads key metadata through the same
`IApiKeyAdminStore` the `apikey` CLI uses, so the dashboard and the CLI act
on one source of truth.
The table shows one row per key:
- key id,
- status (`Active` or `Revoked`),
- display name,
- scopes,
- constraints (rendered as `unconstrained` when none are set),
- created timestamp,
- last-used timestamp.
Key secrets are never listed. Only the peppered hash is stored, and the page
never reconstructs a key. See [Authorization](./Authorization.md#constraint-enforcement)
for what each constraint means and how it is enforced on the gRPC path.
#### Management actions
Create, Rotate, and Revoke controls render only when the signed-in user is
authorized. `DashboardApiKeyAuthorization.CanManage` requires an authenticated
principal that is a member of the LDAP `MxGateway:Ldap:RequiredGroup` — the
same group the dashboard login enforces. An anonymous localhost viewer can read
the table but sees no action controls.
- **Create** opens a dialog for the key id, display name, scope checkboxes
(the `GatewayScopes` catalog), and the optional constraint fields: read and
write subtrees, read and write tag globs, browse subtrees, max write
classification, and the read-alarm-only / read-historized-only flags.
- **Rotate** issues a new secret for an existing key id and invalidates the
old one.
- **Revoke** marks a key revoked; a revoked key cannot be un-revoked.
Create and Rotate return the assembled `mxgw_<keyId>_<secret>` token **once**,
in a one-time banner. It is never shown again, so the operator must copy it
immediately. This mirrors the `apikey create-key` / `rotate-key` CLI.
Every management action appends an `api_key_audit` entry
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`) with
the key id and the caller's remote address. Secrets and pepper values are never
logged.
### Settings page
Show read-only effective configuration:
+260
View File
@@ -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=<groupname>,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=<entered-username>) — 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 = "<sha256 of the password — see below>"
```
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.
@@ -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<DashboardAuthenticator>.Instance);
}
}
@@ -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);
}
@@ -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);
@@ -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";
}
@@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="@DashboardBaseHref" />
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
@@ -2,55 +2,34 @@
@inject IOptions<GatewayOptions> GatewayOptions
<div class="dashboard-shell">
<nav class="navbar navbar-expand-lg bg-body border-bottom dashboard-navbar">
<div class="container-fluid">
<a class="navbar-brand" href="">MXAccess Gateway</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#dashboardNav"
aria-controls="dashboardNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="dashboardNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Overview</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="sessions">Sessions</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="workers">Workers</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="events">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="apikeys">API Keys</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="settings">Settings</NavLink>
</li>
</ul>
<AuthorizeView>
<Authorized Context="authState">
<div class="d-flex align-items-center gap-2">
<span class="navbar-text">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div>
</div>
</nav>
<main class="container-fluid dashboard-content">
<header class="app-bar">
<a class="brand" href=""><span class="mark">&#9646;</span> MXAccess Gateway</a>
<nav class="app-nav">
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink href="sessions">Sessions</NavLink>
<NavLink href="workers">Workers</NavLink>
<NavLink href="events">Events</NavLink>
<NavLink href="galaxy">Galaxy</NavLink>
<NavLink href="apikeys">API Keys</NavLink>
<NavLink href="settings">Settings</NavLink>
</nav>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<div class="app-user">
<span class="meta">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</header>
<main class="page">
@Body
</main>
</div>
@@ -0,0 +1,459 @@
@page "/apikeys"
@page "/dashboard/apikeys"
@inherits DashboardPageBase
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardApiKeyManagementService ApiKeyManagementService
<PageTitle>Dashboard API Keys</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading API keys.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>API Keys</h1>
<div class="text-secondary">@Snapshot.ApiKeys.Count key rows</div>
</div>
@if (CanManageApiKeys)
{
<button type="button" class="btn btn-primary" @onclick="OpenCreateDialog">
Create API Key
</button>
}
</div>
@if (CanManageApiKeys)
{
@if (!string.IsNullOrWhiteSpace(ResultMessage))
{
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
@ResultMessage
@if (!string.IsNullOrWhiteSpace(LastGeneratedApiKey))
{
<div class="mt-2">
<code class="one-time-secret">@LastGeneratedApiKey</code>
</div>
}
</div>
}
@if (IsCreateDialogOpen)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show api-key-create-modal" role="dialog" aria-modal="true" aria-labelledby="createApiKeyTitle">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
<div class="modal-header">
<h2 class="modal-title h5" id="createApiKeyTitle">Create API Key</h2>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseCreateDialog"></button>
</div>
<div class="modal-body">
<div class="api-key-management-grid">
<div class="mb-3">
<label for="keyId" class="form-label">Key ID</label>
<input id="keyId" class="form-control" @bind="CreateModel.KeyId" @bind:event="oninput" />
</div>
<div class="mb-3">
<label for="displayName" class="form-label">Display Name</label>
<input id="displayName" class="form-control" @bind="CreateModel.DisplayName" @bind:event="oninput" />
</div>
</div>
<fieldset class="mb-3">
<legend class="form-label">Scopes</legend>
<div class="scope-grid">
@foreach (string scope in AvailableScopes)
{
<label class="form-check">
<input class="form-check-input" type="checkbox"
checked="@IsScopeSelected(scope)"
@onchange="eventArgs => SetScope(scope, eventArgs)" />
<span class="form-check-label">@scope</span>
</label>
}
</div>
</fieldset>
<div class="api-key-management-grid">
<div class="mb-3">
<label for="readSubtrees" class="form-label">Read subtrees</label>
<textarea id="readSubtrees" class="form-control" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="writeSubtrees" class="form-label">Write subtrees</label>
<textarea id="writeSubtrees" class="form-control" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="readTagGlobs" class="form-label">Read tag globs</label>
<textarea id="readTagGlobs" class="form-control" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="writeTagGlobs" class="form-label">Write tag globs</label>
<textarea id="writeTagGlobs" class="form-control" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="browseSubtrees" class="form-label">Browse subtrees</label>
<textarea id="browseSubtrees" class="form-control" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="maxWriteClassification" class="form-label">Max write classification</label>
<input id="maxWriteClassification" class="form-control" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
</div>
</div>
<div class="d-flex flex-wrap gap-3">
<label class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadAlarmOnly" />
<span class="form-check-label">Read alarm only</span>
</label>
<label class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadHistorizedOnly" />
<span class="form-check-label">Read historized only</span>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled="@IsBusy">Create Key</button>
</div>
</EditForm>
</div>
</div>
</div>
}
}
<section class="dashboard-section">
@if (Snapshot.ApiKeys.Count == 0)
{
<div class="empty-state">No API keys are available for display.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Status</th>
<th scope="col">Display Name</th>
<th scope="col">Scopes</th>
<th scope="col">Constraints</th>
<th scope="col">Created</th>
<th scope="col">Last Used</th>
@if (CanManageApiKeys)
{
<th scope="col">Actions</th>
}
</tr>
</thead>
<tbody>
@foreach (DashboardApiKeySummary key in Snapshot.ApiKeys)
{
<tr>
<td><code>@key.KeyId</code></td>
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
<td>@DashboardDisplay.Text(key.DisplayName)</td>
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
@if (CanManageApiKeys)
{
<td>
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
<button type="button" class="btn btn-outline-secondary"
disabled="@IsBusy"
@onclick="() => RotateApiKeyAsync(key.KeyId)">
Rotate
</button>
@if (key.RevokedUtc is null)
{
<button type="button" class="btn btn-outline-danger"
disabled="@IsBusy"
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
Revoke
</button>
}
</div>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@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<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> 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<string> 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<string> parts, string name, IReadOnlyList<string> values)
{
if (values.Count > 0)
{
parts.Add($"{name}=[{string.Join(", ", values)}]");
}
}
private static IReadOnlyList<string> 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<string> 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;
}
}
}
@@ -19,12 +19,12 @@ else
</div>
<section class="metric-grid">
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" Detail="@DeployAge()" />
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" Detail="@DeployAge()" Wide="true" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" Wide="true" />
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" Detail="dynamic, deployed" />
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" />
</section>
@if (Snapshot.Galaxy.Status == DashboardGalaxyStatus.Unknown)
@@ -1,4 +1,4 @@
<div class="card metric-card h-100">
<div class="card metric-card h-100@(Wide ? " metric-card-wide" : string.Empty)">
<div class="card-body">
<div class="metric-label">@Label</div>
<div class="metric-value">@Value</div>
@@ -18,4 +18,8 @@
[Parameter]
public string? Detail { get; set; }
/// <summary>Spans the card across two grid columns for long values such as timestamps.</summary>
[Parameter]
public bool Wide { get; set; }
}
@@ -1,4 +1,4 @@
<span class="badge @CssClass">@Text</span>
<span class="chip @CssClass">@Text</span>
@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"
};
}
@@ -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<GatewayOptions> options)
{
public bool CanManage(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return false;
}
string requiredGroup = options.Value.Ldap.RequiredGroup;
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value);
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
}
}
@@ -0,0 +1,9 @@
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardApiKeyManagementRequest(
string KeyId,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints);
@@ -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);
}
}
@@ -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<DashboardApiKeyManagementResult> 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<DashboardApiKeyManagementResult> 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<DashboardApiKeyManagementResult> 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}";
}
}
@@ -0,0 +1,12 @@
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardApiKeySummary(
string KeyId,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
@@ -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]";
}
}
}
@@ -169,11 +169,17 @@ public static class DashboardEndpointRouteBuilderExtensions
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{HtmlEncoder.Default.Encode(title)}</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
</head>
<body class="dashboard-body">
<main class="container py-5">
<h1 class="h3 mb-4">{HtmlEncoder.Default.Encode(title)}</h1>
<header class="app-bar">
<span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span>
</header>
<main class="page">
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
</div>
{body}
</main>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
@@ -0,0 +1,23 @@
using System.Security.Claims;
namespace MxGateway.Server.Dashboard;
public interface IDashboardApiKeyManagementService
{
bool CanManage(ClaimsPrincipal user);
Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken);
Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
}
@@ -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();
}
}
@@ -0,0 +1,106 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed class GalaxyHierarchyIndex
{
private GalaxyHierarchyIndex(
IReadOnlyList<GalaxyObjectView> objectViews,
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress)
{
ObjectViews = objectViews;
ObjectViewsById = objectViewsById;
TagsByAddress = tagsByAddress;
}
public static GalaxyHierarchyIndex Empty { get; } = new(
Array.Empty<GalaxyObjectView>(),
new Dictionary<int, GalaxyObjectView>(),
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
{
if (objects.Count == 0)
{
return Empty;
}
Dictionary<int, GalaxyObject> objectsById = new();
foreach (GalaxyObject obj in objects)
{
objectsById.TryAdd(obj.GobjectId, obj);
}
List<GalaxyObjectView> views = new(objects.Count);
Dictionary<int, GalaxyObjectView> viewsById = new();
Dictionary<string, GalaxyTagLookup> 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<int, GalaxyObject> objectsById)
{
Stack<string> names = new();
HashSet<int> 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;
}
}
@@ -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<string>? browseSubtreeGlobs = null)
{
return Project(
entry,
request,
browseSubtreeGlobs,
offset: 0,
pageSize: int.MaxValue);
}
public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
IReadOnlyList<string>? 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<GalaxyObjectView> 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<GalaxyObject> 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<GalaxyObjectView> 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<string>? 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<string>? 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<string>()).Order(StringComparer.OrdinalIgnoreCase));
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash, 0, 12);
}
}
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed record GalaxyHierarchyQueryResult(
IReadOnlyList<GalaxyObject> Objects,
int TotalObjectCount,
string FilterSignature);
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed record GalaxyObjectView(
GalaxyObject Object,
string ContainedPath,
int Depth);
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed record GalaxyTagLookup(
GalaxyObject Object,
GalaxyAttribute? Attribute,
string ContainedPath);
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MxGateway.Tests")]
[assembly: InternalsVisibleTo("MxGateway.IntegrationTests")]
@@ -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<ApiKeyConstraints>(json, JsonOptions) ?? ApiKeyConstraints.Empty;
}
}
@@ -0,0 +1,43 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyConstraints(
IReadOnlyList<string> ReadSubtrees,
IReadOnlyList<string> WriteSubtrees,
IReadOnlyList<string> ReadTagGlobs,
IReadOnlyList<string> WriteTagGlobs,
int? MaxWriteClassification,
IReadOnlyList<string> BrowseSubtrees,
bool ReadAlarmOnly,
bool ReadHistorizedOnly)
{
public static ApiKeyConstraints Empty { get; } = new(
ReadSubtrees: Array.Empty<string>(),
WriteSubtrees: Array.Empty<string>(),
ReadTagGlobs: Array.Empty<string>(),
WriteTagGlobs: Array.Empty<string>(),
MaxWriteClassification: null,
BrowseSubtrees: Array.Empty<string>(),
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;
}
@@ -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<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken)
{
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasReadConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
}
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
}
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken)
{
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasReadConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
}
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
}
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
}
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken)
{
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasWriteConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
}
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
{
return Task.FromResult<ConstraintFailure?>(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<ConstraintFailure?>(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<ConstraintFailure?>(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<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
}
if (attribute.SecurityClassification > maxClassification)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure(
"max_write_classification",
$"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}."));
}
}
return Task.FromResult<ConstraintFailure?>(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<string> subtreeGlobs,
IReadOnlyList<string> 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));
}
}
@@ -0,0 +1,3 @@
namespace MxGateway.Server.Security.Authorization;
public sealed record ConstraintFailure(string ConstraintName, string Message);
@@ -0,0 +1,33 @@
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Sessions;
namespace MxGateway.Server.Security.Authorization;
public interface IConstraintEnforcer
{
Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken);
Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken);
Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken);
Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
string target,
ConstraintFailure failure,
CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace MxGateway.Server.Sessions;
public sealed record SessionItemRegistration(
int ServerHandle,
int ItemHandle,
string TagAddress);
@@ -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<GatewayOptions> options,
ILogger<SessionLeaseMonitorHostedService> 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)
{
}
}
}
+261 -118
View File
@@ -1,165 +1,308 @@
: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-card-wide { grid-column: span 2; }
.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);
/* break-word, not anywhere: keep date/number tokens whole, wrap at spaces. */
overflow-wrap: break-word;
}
.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;
/* break-word, not anywhere: only split a word when it genuinely cannot
fit, so the column keeps whole words like "unconstrained" intact. */
overflow-wrap: break-word;
}
.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);
}
/* Status chips never wrap, even in a narrow table column. */
.chip { white-space: nowrap; }
/* ── 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; white-space: nowrap; }
.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; }
}
+379
View File
@@ -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;
}
@@ -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);
}
}
@@ -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<string>([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<ApiKeyCreateRequest> CreatedRequests { get; } = [];
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
CreateCount++;
CreatedRequests.Add(request);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
CancellationToken cancellationToken)
{
RevokeCount++;
LastRevokedKeyId = keyId;
return Task.FromResult(RevokeResult);
}
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
{
LastRotatedSecretHash = secretHash;
return Task.FromResult(RotateResult);
}
}
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{
public List<ApiKeyAuditEntry> Entries { get; } = [];
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
}
}
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}");
}
}
}
@@ -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);
}
}
@@ -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<RpcException>(
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<RpcException>(
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<RpcException>(
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<GalaxyRepositoryGrpcService>.Instance);
}
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> 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<GalaxyObject> 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<GalaxyObject> 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<object, object> 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<string, List<AuthProperty>>(StringComparer.Ordinal));
protected override IDictionary<object, object> UserStateCore => userState;
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options)
{
throw new NotSupportedException();
}
}
}
@@ -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<string>(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<GalaxyObject> 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<ApiKeyAuditEntry> Entries { get; } = [];
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
}
}
}