Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c640306e5 | |||
| a67a5a4857 | |||
| e00ee61cf0 | |||
| 271bf7edff | |||
| 3397e99783 | |||
| f598b3a647 | |||
| 509b0118d4 | |||
| 298836d2f3 | |||
| 96bea1d478 |
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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">▮</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">▮</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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,6 +447,13 @@ public sealed class AlarmCommandExecutorTests
|
||||
return QueryResult;
|
||||
}
|
||||
|
||||
public int PollCount { get; private set; }
|
||||
|
||||
public void PollOnce()
|
||||
{
|
||||
PollCount++;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,13 @@ public sealed class AlarmCommandHandlerTests
|
||||
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult;
|
||||
|
||||
public int PollCount { get; private set; }
|
||||
|
||||
public void PollOnce()
|
||||
{
|
||||
PollCount++;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
|
||||
@@ -318,6 +318,13 @@ public sealed class AlarmDispatcherTests
|
||||
return SnapshotResult;
|
||||
}
|
||||
|
||||
public int PollCount { get; private set; }
|
||||
|
||||
public void PollOnce()
|
||||
{
|
||||
PollCount++;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -180,6 +181,153 @@ public sealed class MxAccessStaSessionTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gap 1: Verifies that when MxAccessStaSession is created with an alarm handler factory,
|
||||
/// a SubscribeAlarms command dispatched through the session reaches the handler.
|
||||
/// This proves the fix in WorkerPipeSession (and the new internal constructor) correctly
|
||||
/// wires the factory rather than leaving alarmCommandHandler null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_WithAlarmCommandHandlerFactory_SubscribeAlarmsCommandReachesHandler()
|
||||
{
|
||||
FakeAlarmCommandHandler handler = new();
|
||||
FakeMxAccessComObjectFactory factory = new();
|
||||
FakeMxAccessEventSink eventSink = new();
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(
|
||||
runtime,
|
||||
factory,
|
||||
eventSink,
|
||||
new MxAccessEventQueue(),
|
||||
_eq => handler);
|
||||
|
||||
await session.StartAsync("session-1", workerProcessId: 1);
|
||||
|
||||
StaCommand subscribeCommand = new StaCommand(
|
||||
"session-1",
|
||||
"corr-1",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.SubscribeAlarms,
|
||||
SubscribeAlarms = new SubscribeAlarmsCommand
|
||||
{
|
||||
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
||||
},
|
||||
});
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(subscribeCommand);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.True(handler.IsSubscribed);
|
||||
Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gap 1: Verifies that when MxAccessStaSession is created with the default
|
||||
/// parameterless constructor (no alarm factory), SubscribeAlarms returns
|
||||
/// InvalidRequest with "alarm consumer not configured" diagnostic.
|
||||
/// This validates the baseline before the fix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest()
|
||||
{
|
||||
FakeMxAccessComObjectFactory factory = new();
|
||||
FakeMxAccessEventSink eventSink = new();
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
// Use the 4-arg (no factory) constructor — equivalent to the old MxAccessStaSession()
|
||||
using MxAccessStaSession session = new(runtime, factory, eventSink);
|
||||
|
||||
await session.StartAsync("session-1", workerProcessId: 1);
|
||||
|
||||
StaCommand subscribeCommand = new StaCommand(
|
||||
"session-1",
|
||||
"corr-1",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.SubscribeAlarms,
|
||||
SubscribeAlarms = new SubscribeAlarmsCommand
|
||||
{
|
||||
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
||||
},
|
||||
});
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(subscribeCommand);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
||||
Assert.Contains("alarm", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gap 2: Verifies that after StartAsync with an alarm handler factory, the STA poll
|
||||
/// loop calls PollOnce on the handler via the STA within a reasonable timeout.
|
||||
/// This proves polling is driven by the STA rather than the consumer's internal timer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_WithAlarmCommandHandlerFactory_PollOnceCalledViaSta()
|
||||
{
|
||||
FakeAlarmCommandHandler handler = new();
|
||||
FakeMxAccessComObjectFactory factory = new();
|
||||
FakeMxAccessEventSink eventSink = new();
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(
|
||||
runtime,
|
||||
factory,
|
||||
eventSink,
|
||||
new MxAccessEventQueue(),
|
||||
_eq => handler);
|
||||
|
||||
await session.StartAsync("session-1", workerProcessId: 1);
|
||||
|
||||
// Wait up to 3s for at least one PollOnce call from the STA poll loop.
|
||||
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
while (handler.PollCount == 0 && !timeout.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.True(handler.PollCount > 0,
|
||||
"Expected PollOnce to be called at least once by the STA poll loop within 3 seconds.");
|
||||
Assert.NotNull(handler.LastPollThreadId);
|
||||
Assert.Equal(runtime.StaThreadId, handler.LastPollThreadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gap 2: Verifies that the STA poll loop stops when the session is disposed —
|
||||
/// no further PollOnce calls after disposal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_StopsAlarmPollLoop()
|
||||
{
|
||||
FakeAlarmCommandHandler handler = new();
|
||||
FakeMxAccessComObjectFactory factory = new();
|
||||
FakeMxAccessEventSink eventSink = new();
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
MxAccessStaSession session = new(
|
||||
runtime,
|
||||
factory,
|
||||
eventSink,
|
||||
new MxAccessEventQueue(),
|
||||
_eq => handler);
|
||||
|
||||
await session.StartAsync("session-1", workerProcessId: 1);
|
||||
|
||||
// Wait for at least one poll to occur, then dispose.
|
||||
using CancellationTokenSource initTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
while (handler.PollCount == 0 && !initTimeout.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.True(handler.PollCount > 0, "Prerequisite: poll loop must have fired before dispose.");
|
||||
|
||||
session.Dispose();
|
||||
int pollCountAtDispose = handler.PollCount;
|
||||
|
||||
// Wait 1 second and verify no further polls occur.
|
||||
await Task.Delay(1000);
|
||||
Assert.Equal(pollCountAtDispose, handler.PollCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Noop STA COM apartment initializer for testing.
|
||||
/// </summary>
|
||||
@@ -199,4 +347,61 @@ public sealed class MxAccessStaSessionTests
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake alarm command handler that records calls and tracks poll thread.
|
||||
/// </summary>
|
||||
private sealed class FakeAlarmCommandHandler : IAlarmCommandHandler
|
||||
{
|
||||
private readonly object gate = new object();
|
||||
private int pollCount;
|
||||
private int? lastPollThreadId;
|
||||
|
||||
public bool IsSubscribed { get; private set; }
|
||||
public string? LastSubscription { get; private set; }
|
||||
|
||||
public int PollCount
|
||||
{
|
||||
get { lock (gate) return pollCount; }
|
||||
}
|
||||
|
||||
public int? LastPollThreadId
|
||||
{
|
||||
get { lock (gate) return lastPollThreadId; }
|
||||
}
|
||||
|
||||
public void Subscribe(string subscription, string sessionId)
|
||||
{
|
||||
IsSubscribed = true;
|
||||
LastSubscription = subscription;
|
||||
}
|
||||
|
||||
public void Unsubscribe()
|
||||
{
|
||||
IsSubscribed = false;
|
||||
}
|
||||
|
||||
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
|
||||
string operatorNode, string operatorDomain, string operatorFullName)
|
||||
=> 0;
|
||||
|
||||
public int AcknowledgeByName(string alarmName, string providerName, string groupName,
|
||||
string comment, string operatorUser, string operatorNode,
|
||||
string operatorDomain, string operatorFullName)
|
||||
=> 0;
|
||||
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
|
||||
=> Array.Empty<ActiveAlarmSnapshot>();
|
||||
|
||||
public void PollOnce()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
pollCount++;
|
||||
lastPollThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public sealed class WorkerPipeSession
|
||||
options,
|
||||
() => Process.GetCurrentProcess().Id,
|
||||
new WorkerPipeSessionOptions(),
|
||||
() => new MxAccessStaSession(),
|
||||
() => new MxAccessStaSession(eq => new AlarmCommandHandler(eq)),
|
||||
logger)
|
||||
{
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public sealed class WorkerPipeSession
|
||||
options,
|
||||
processIdProvider,
|
||||
new WorkerPipeSessionOptions(),
|
||||
() => new MxAccessStaSession(),
|
||||
() => new MxAccessStaSession(eq => new AlarmCommandHandler(eq)),
|
||||
logger: null)
|
||||
{
|
||||
}
|
||||
@@ -746,7 +746,7 @@ public sealed class WorkerPipeSession
|
||||
|
||||
private async Task<WorkerReady> InitializeMxAccessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_runtimeSession = new MxAccessStaSession();
|
||||
_runtimeSession = new MxAccessStaSession(eq => new AlarmCommandHandler(eq));
|
||||
try
|
||||
{
|
||||
return await _runtimeSession
|
||||
|
||||
@@ -160,6 +160,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void PollOnce()
|
||||
{
|
||||
AlarmDispatcher? d;
|
||||
lock (syncRoot) d = dispatcher;
|
||||
// No-op when not yet subscribed or already disposed.
|
||||
d?.PollOnce();
|
||||
}
|
||||
|
||||
private AlarmDispatcher GetDispatcherOrThrow()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
@@ -226,4 +235,14 @@ public interface IAlarmCommandHandler : IDisposable
|
||||
/// prefix matched against <c>AlarmFullReference</c>.
|
||||
/// </summary>
|
||||
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
|
||||
|
||||
/// <summary>
|
||||
/// Drives a single poll of the underlying alarm consumer on the
|
||||
/// caller's thread. This is a no-op when there is no active
|
||||
/// subscription. In production the caller is the worker's STA
|
||||
/// (marshalled via <c>StaRuntime.InvokeAsync</c>), which satisfies
|
||||
/// the <c>ThreadingModel=Apartment</c> requirement of
|
||||
/// <c>wwAlarmConsumerClass</c>.
|
||||
/// </summary>
|
||||
void PollOnce();
|
||||
}
|
||||
|
||||
@@ -119,6 +119,17 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
ackOperatorFullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives a single synchronous poll of the underlying consumer.
|
||||
/// Must be called on the STA thread that owns the wnwrap COM object.
|
||||
/// No-op if the dispatcher has been disposed.
|
||||
/// </summary>
|
||||
public void PollOnce()
|
||||
{
|
||||
if (disposed) return;
|
||||
consumer.PollOnce();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the currently-active alarm set as
|
||||
/// <see cref="ActiveAlarmSnapshot"/> protos for the
|
||||
|
||||
@@ -85,4 +85,17 @@ public interface IMxAccessAlarmConsumer : IDisposable
|
||||
/// to seed local Part 9 state.
|
||||
/// </summary>
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms();
|
||||
|
||||
/// <summary>
|
||||
/// Drives a single synchronous poll of the underlying alarm source.
|
||||
/// Implementations that use an internal <see cref="System.Threading.Timer"/>
|
||||
/// are constructed with <c>pollIntervalMilliseconds=0</c> in production so
|
||||
/// the timer is disabled; the worker's STA drives polls via
|
||||
/// <c>StaRuntime.InvokeAsync</c> instead, satisfying the
|
||||
/// <c>ThreadingModel=Apartment</c> requirement of
|
||||
/// <c>wwAlarmConsumerClass</c>. Fake implementations should no-op.
|
||||
/// This method must be invoked on the thread that created the consumer
|
||||
/// (the worker's STA in production).
|
||||
/// </summary>
|
||||
void PollOnce();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
{
|
||||
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly IMxAccessComObjectFactory factory;
|
||||
private readonly IMxAccessEventSink eventSink;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
@@ -19,6 +21,8 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
private StaCommandDispatcher? commandDispatcher;
|
||||
private MxAccessSession? session;
|
||||
private IAlarmCommandHandler? alarmCommandHandler;
|
||||
private CancellationTokenSource? alarmPollCts;
|
||||
private Task? alarmPollTask;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -32,6 +36,22 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default STA runtime,
|
||||
/// factory, and event queue, but with a custom alarm-command handler factory. The factory is
|
||||
/// invoked on the STA thread during
|
||||
/// <see cref="StartAsync(string, int, CancellationToken)"/>; pass <c>null</c> to opt out
|
||||
/// of alarm-side commands.
|
||||
/// </summary>
|
||||
internal MxAccessStaSession(Func<MxAccessEventQueue, IAlarmCommandHandler>? alarmCommandHandlerFactory)
|
||||
: this(
|
||||
new StaRuntime(),
|
||||
new MxAccessComObjectFactory(),
|
||||
new MxAccessEventQueue(),
|
||||
alarmCommandHandlerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom STA runtime and factory.
|
||||
/// </summary>
|
||||
@@ -60,6 +80,26 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom event queue
|
||||
/// and an alarm-command handler factory.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="factory">MXAccess COM object factory.</param>
|
||||
/// <param name="eventQueue">Event queue for buffering MXAccess events.</param>
|
||||
/// <param name="alarmCommandHandlerFactory">
|
||||
/// Factory that constructs the alarm-command handler from the event queue.
|
||||
/// Pass <c>null</c> to opt out of alarm-side commands.
|
||||
/// </param>
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
MxAccessEventQueue eventQueue,
|
||||
Func<MxAccessEventQueue, IAlarmCommandHandler>? alarmCommandHandlerFactory)
|
||||
: this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue, alarmCommandHandlerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with all dependencies.
|
||||
/// </summary>
|
||||
@@ -122,14 +162,14 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
/// <param name="workerProcessId">Worker process identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready message.</returns>
|
||||
public Task<WorkerReady> StartAsync(
|
||||
public async Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
staRuntime.Start();
|
||||
|
||||
return staRuntime.InvokeAsync(
|
||||
WorkerReady ready = await staRuntime.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
if (session is not null)
|
||||
@@ -151,7 +191,61 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
|
||||
return session.CreateWorkerReady(workerProcessId);
|
||||
},
|
||||
cancellationToken);
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (alarmCommandHandler is not null)
|
||||
{
|
||||
alarmPollCts = new CancellationTokenSource();
|
||||
alarmPollTask = RunAlarmPollLoopAsync(alarmCommandHandler, alarmPollCts.Token);
|
||||
}
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
private Task RunAlarmPollLoopAsync(
|
||||
IAlarmCommandHandler handler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(async () =>
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(AlarmPollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await staRuntime.InvokeAsync(
|
||||
() => handler.PollOnce(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// STA runtime or alarm handler disposed — stop the loop gracefully.
|
||||
return;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// STA runtime shutting down — stop the loop gracefully.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -307,6 +401,30 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
|
||||
commandDispatcher?.RequestShutdown();
|
||||
|
||||
// Cancel the STA poll loop before disposing the alarm handler.
|
||||
// The loop references the alarm handler and must be stopped first
|
||||
// so that no further PollOnce calls race with disposal.
|
||||
CancellationTokenSource? pollCtsToDispose = alarmPollCts;
|
||||
Task? pollTaskToAwait = alarmPollTask;
|
||||
alarmPollCts = null;
|
||||
alarmPollTask = null;
|
||||
if (pollCtsToDispose is not null)
|
||||
{
|
||||
pollCtsToDispose.Cancel();
|
||||
if (pollTaskToAwait is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pollTaskToAwait.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — poll loop cancellation must not block data shutdown.
|
||||
}
|
||||
}
|
||||
pollCtsToDispose.Dispose();
|
||||
}
|
||||
|
||||
// Stop the alarm consumer's polling timer and tear down the
|
||||
// dispatcher BEFORE the data-side cleanup begins. The alarm
|
||||
// consumer holds a wnwrap COM RCW that needs the STA pump to
|
||||
@@ -382,6 +500,16 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
|
||||
RequestShutdown();
|
||||
|
||||
// Cancel and discard the STA poll loop.
|
||||
CancellationTokenSource? pollCtsToDispose = alarmPollCts;
|
||||
alarmPollCts = null;
|
||||
alarmPollTask = null;
|
||||
if (pollCtsToDispose is not null)
|
||||
{
|
||||
try { pollCtsToDispose.Cancel(); } catch { }
|
||||
try { pollCtsToDispose.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
|
||||
alarmCommandHandler = null;
|
||||
if (alarmHandlerToDispose is not null)
|
||||
|
||||
@@ -63,8 +63,16 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
private bool subscribed;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — creates the wnwrap COM object on the
|
||||
/// current thread (must be the worker's STA) and disables the
|
||||
/// internal <see cref="Timer"/> (<c>pollIntervalMilliseconds=0</c>).
|
||||
/// Polling is driven externally by the STA via
|
||||
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c> so
|
||||
/// that every COM call stays on the STA that owns the apartment.
|
||||
/// </summary>
|
||||
public WnWrapAlarmConsumer()
|
||||
: this(new wwAlarmConsumerClass(), DefaultPollIntervalMilliseconds, DefaultMaxAlarmsPerFetch)
|
||||
: this(new wwAlarmConsumerClass(), pollIntervalMilliseconds: 0, DefaultMaxAlarmsPerFetch)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user