Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df2ef0d1e | |||
| e5785fd769 | |||
| 22370ca4da | |||
| e0a3fbf35b | |||
| 161ed6f80d | |||
| e57d864ab2 | |||
| 5539ec8542 | |||
| 73e54e252d | |||
| 70d959bd9b | |||
| 0c5b796e2e | |||
| 47dc9d865f | |||
| 4f757e3c0c | |||
| 2f0ee4c961 | |||
| 0859d47f75 | |||
| 7ea8358c06 | |||
| a5944bbe5d | |||
| 04bce3ff9f | |||
| 9572045787 | |||
| 7e1af37eb1 | |||
| 05009d7370 | |||
| f4dc11bae4 | |||
| c3b466e13d | |||
| 792e3f9445 | |||
| ae281d06bb | |||
| 3ca2799c90 | |||
| 459a88b3e7 | |||
| 437ab65fc1 | |||
| 679562e5ed | |||
| dbf550da8b | |||
| 3965a7741e | |||
| abb2cfb84b | |||
| 4e0d8ccfed | |||
| a935aa8b7c | |||
| 9912389fa1 | |||
| f1129b969d | |||
| c51b6f9ce4 | |||
| e39972357b | |||
| 9ad17e2964 | |||
| ef0a883a81 | |||
| 62ba5e9487 | |||
| 136614be94 | |||
| a912bffad5 |
@@ -147,3 +147,8 @@ generated-scratch/
|
|||||||
|
|
||||||
# Keep empty directories with .gitkeep files when needed
|
# Keep empty directories with .gitkeep files when needed
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
|
||||||
|
# Documentation review artifacts (CommentChecker output)
|
||||||
|
*-docs-issues.md
|
||||||
|
*-docs-fixed.md
|
||||||
|
*-docs-final.md
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
- `glauth.md` — shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ the affected stream while the MXAccess session remains active.
|
|||||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||||
|
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
|
||||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
||||||
|
|
||||||
## Meter and OpenTelemetry Compatibility
|
## Meter and OpenTelemetry Compatibility
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit
|
|||||||
```csharp
|
```csharp
|
||||||
public sealed class GatewayMetrics : IDisposable
|
public sealed class GatewayMetrics : IDisposable
|
||||||
{
|
{
|
||||||
public const string MeterName = "ZB.MOM.WW.MxGateway.Server";
|
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||||
|
|
||||||
public GatewayMetrics()
|
public GatewayMetrics()
|
||||||
{
|
{
|
||||||
@@ -50,12 +50,12 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
|
|||||||
|
|
||||||
### Histograms
|
### Histograms
|
||||||
|
|
||||||
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`):
|
Histograms record durations in seconds (the `unit` argument on `CreateHistogram`):
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
```
|
```
|
||||||
|
|
||||||
| Instrument | Tags | What it measures |
|
| Instrument | Tags | What it measures |
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
# GLAuth — LDAP authn reference for mxaccessgw
|
# GLAuth — LDAP authn reference for mxaccessgw
|
||||||
|
|
||||||
GLAuth is a lightweight LDAP server installed on this dev box at
|
> **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
|
||||||
`C:\publish\glauth\` and run as a Windows service via NSSM. It already
|
> Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
|
||||||
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa
|
> the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
|
||||||
Admin UI's cookie login; this doc captures everything mxaccessgw needs
|
> The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
|
||||||
to consume the same directory so a single set of dev credentials covers
|
|
||||||
both stacks.
|
|
||||||
|
|
||||||
The authoritative copy of LmxOpcUa's reference lives at
|
GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
|
||||||
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to
|
OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
|
||||||
mxaccessgw — what users + groups are already provisioned, how to bind
|
docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
|
||||||
against them, and what's needed to add a gw-specific role.
|
that directory so a single set of dev credentials covers all stacks.
|
||||||
|
|
||||||
|
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
|
||||||
|
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
|
||||||
|
kept only as a rollback option. Do not edit or restart it for new work.)*
|
||||||
|
|
||||||
|
The single source of truth for the shared GLAuth is
|
||||||
|
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
|
||||||
|
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
|
||||||
|
what users + groups are provisioned, how to bind against them, and what's needed to add a
|
||||||
|
gw-specific role.
|
||||||
|
|
||||||
## Connection details
|
## Connection details
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Protocol | LDAP (unencrypted) |
|
| Protocol | LDAP (unencrypted) |
|
||||||
| Host | `localhost` |
|
| Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
|
||||||
| Port | `3893` |
|
| Port | `3893` |
|
||||||
| LDAPS | disabled in dev (set `[ldaps]` block to enable) |
|
| LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
|
||||||
| Base DN | `dc=lmxopcua,dc=local` |
|
| Base DN | `dc=zb,dc=local` |
|
||||||
| Bind DN format | `cn={username},dc=lmxopcua,dc=local` |
|
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
||||||
| Group OU | `ou=<groupname>,ou=groups,dc=lmxopcua,dc=local` |
|
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
|
||||||
|
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
||||||
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
||||||
|
|
||||||
## Pre-existing groups (LmxOpcUa role taxonomy)
|
## Pre-existing groups (LmxOpcUa role taxonomy)
|
||||||
@@ -33,11 +42,11 @@ LmxOpcUa write rights doesn't need a second account for the gw.
|
|||||||
|
|
||||||
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|
| 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) |
|
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=zb,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) |
|
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=zb,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
|
||||||
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=lmxopcua,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=zb,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
||||||
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=lmxopcua,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
|
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=zb,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 |
|
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=zb,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
|
||||||
|
|
||||||
**A user can be in multiple groups** — `othergroups = [...]` in the
|
**A user can be in multiple groups** — `othergroups = [...]` in the
|
||||||
config is a list. `admin` is the canonical example (in every role
|
config is a list. `admin` is the canonical example (in every role
|
||||||
@@ -59,20 +68,26 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
|
|||||||
`readonly` is the right "negative" case for proving Browse-OK /
|
`readonly` is the right "negative" case for proving Browse-OK /
|
||||||
Write-denied.
|
Write-denied.
|
||||||
|
|
||||||
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy:
|
The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
|
||||||
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the
|
`GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
|
||||||
dashboard login and `DashboardLdapLiveTests` require `admin` to be a
|
These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
|
||||||
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline
|
The dashboard test users are **`multi-role`/`password`** (Administrator) and
|
||||||
GLAuth config — it must be provisioned before dashboard authn or the
|
**`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
|
||||||
LDAP live tests work. See [Provisioning the GwAdmin
|
See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
|
||||||
group](#provisioning-the-gwadmin-group) below.
|
(now-retired) per-box procedure and for the shared-config equivalent.
|
||||||
|
|
||||||
|
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
||||||
|
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
||||||
|
> maps to `Viewer`. This is a pure value rename via
|
||||||
|
> `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This
|
||||||
|
> dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.)
|
||||||
|
|
||||||
## Two bind patterns
|
## Two bind patterns
|
||||||
|
|
||||||
### 1. Direct bind (simplest)
|
### 1. Direct bind (simplest)
|
||||||
|
|
||||||
```
|
```
|
||||||
DN: cn=admin,dc=lmxopcua,dc=local
|
DN: cn=admin,dc=zb,dc=local
|
||||||
Password: admin123
|
Password: admin123
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,9 +99,9 @@ by `sAMAccountName`, not `cn`. Use this only for dev convenience.
|
|||||||
### 2. Bind-then-search (production-grade)
|
### 2. Bind-then-search (production-grade)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Bind as the service account (cn=serviceaccount,dc=lmxopcua,dc=local
|
1. Bind as the service account (cn=serviceaccount,dc=zb,dc=local
|
||||||
/ serviceaccount123).
|
/ serviceaccount123).
|
||||||
2. Search under dc=lmxopcua,dc=local with filter
|
2. Search under dc=zb,dc=local with filter
|
||||||
(uid=<entered-username>) — or any attribute the deployment
|
(uid=<entered-username>) — or any attribute the deployment
|
||||||
identifies users by. GLAuth populates uid + cn.
|
identifies users by. GLAuth populates uid + cn.
|
||||||
3. Read the returned entry's DN + memberOf list (groups).
|
3. Read the returned entry's DN + memberOf list (groups).
|
||||||
@@ -112,12 +127,12 @@ record:
|
|||||||
```yaml
|
```yaml
|
||||||
ldap:
|
ldap:
|
||||||
enabled: true
|
enabled: true
|
||||||
server: localhost
|
server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
|
||||||
port: 3893
|
port: 3893
|
||||||
useTls: false
|
useTls: false
|
||||||
allowInsecureLdap: true # dev only
|
allowInsecureLdap: true # dev only
|
||||||
searchBase: "dc=lmxopcua,dc=local"
|
searchBase: "dc=zb,dc=local"
|
||||||
serviceAccountDn: "cn=serviceaccount,dc=lmxopcua,dc=local"
|
serviceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||||
serviceAccountPassword: "serviceaccount123"
|
serviceAccountPassword: "serviceaccount123"
|
||||||
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
|
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
|
||||||
displayNameAttribute: "cn"
|
displayNameAttribute: "cn"
|
||||||
@@ -131,19 +146,35 @@ ldap:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`groupAttribute` returns full DNs like
|
`groupAttribute` returns full DNs like
|
||||||
`ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` — the authenticator
|
`ou=ReadOnly,ou=groups,dc=zb,dc=local` — the authenticator
|
||||||
should strip the leading `ou=` (or `cn=` against AD) RDN value and
|
should strip the leading `ou=` (or `cn=` against AD) RDN value and
|
||||||
look that up in `groupToRole`.
|
look that up in `groupToRole`.
|
||||||
|
|
||||||
## Provisioning the GwAdmin group
|
## Provisioning the GwAdmin group
|
||||||
|
|
||||||
|
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
|
||||||
|
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
|
||||||
|
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose up -d --force-recreate
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
|
||||||
|
> rollback reference only — do not use it for new provisioning.
|
||||||
|
|
||||||
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
||||||
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
||||||
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
||||||
`admin` until a `GwAdmin` group exists and `admin` is a member.
|
logins unless the user is a member of `GwAdmin`.
|
||||||
GLAuth's baseline config ships only the five LmxOpcUa role groups, so
|
The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
|
||||||
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP
|
config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
|
||||||
server:
|
`multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**RETIRED — per-box provisioning (reference/rollback only):**
|
||||||
|
|
||||||
1. Edit `C:\publish\glauth\glauth.cfg`
|
1. Edit `C:\publish\glauth\glauth.cfg`
|
||||||
2. Append the group:
|
2. Append the group:
|
||||||
@@ -172,7 +203,7 @@ server:
|
|||||||
4. `nssm restart GLAuth`
|
4. `nssm restart GLAuth`
|
||||||
|
|
||||||
After the restart, `admin`'s `memberOf` includes
|
After the restart, `admin`'s `memberOf` includes
|
||||||
`ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local`, which the authenticator
|
`ou=GwAdmin,ou=groups,dc=zb,dc=local`, which the authenticator
|
||||||
strips to `GwAdmin` and matches against `RequiredGroup`. The same
|
strips to `GwAdmin` and matches against `RequiredGroup`. The same
|
||||||
pattern applies to any future permission that doesn't fit the existing
|
pattern applies to any future permission that doesn't fit the existing
|
||||||
five roles.
|
five roles.
|
||||||
@@ -193,15 +224,16 @@ echo -n "yourpassword" | openssl dgst -sha256
|
|||||||
|
|
||||||
## Quick verification
|
## Quick verification
|
||||||
|
|
||||||
From mxaccessgw's dev box, prove the directory is reachable:
|
From mxaccessgw's dev box, prove the shared directory is reachable:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
||||||
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893")
|
# (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
|
||||||
|
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
|
||||||
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
||||||
$ldap.SessionOptions.ProtocolVersion = 3
|
$ldap.SessionOptions.ProtocolVersion = 3
|
||||||
$ldap.SessionOptions.SecureSocketLayer = $false
|
$ldap.SessionOptions.SecureSocketLayer = $false
|
||||||
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=lmxopcua,dc=local","admin123")
|
$cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
|
||||||
$ldap.Bind($cred)
|
$ldap.Bind($cred)
|
||||||
"Bind OK"
|
"Bind OK"
|
||||||
```
|
```
|
||||||
@@ -209,17 +241,32 @@ $ldap.Bind($cred)
|
|||||||
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldap://localhost:3893 \
|
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
-D "cn=admin,dc=lmxopcua,dc=local" -w admin123 \
|
-D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
|
||||||
-b "dc=lmxopcua,dc=local" "(uid=admin)"
|
-b "dc=zb,dc=local" "(uid=multi-role)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The response should list `admin`'s entry with `memberOf` populated for
|
The response should list `multi-role`'s entry with `memberOf` including
|
||||||
all five role groups — plus `GwAdmin` once the gateway-specific group
|
`ou=GwAdmin,ou=groups,dc=zb,dc=local`.
|
||||||
is provisioned.
|
|
||||||
|
|
||||||
## Service management
|
## Service management
|
||||||
|
|
||||||
|
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
|
||||||
|
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
|
||||||
|
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
|
||||||
|
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
|
||||||
|
>
|
||||||
|
> **Active (shared) management:**
|
||||||
|
> ```bash
|
||||||
|
> ssh 10.100.0.35
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose ps # check container status
|
||||||
|
> docker compose up -d --force-recreate # apply config.toml changes
|
||||||
|
> docker compose logs -f # tail logs
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**RETIRED — per-box NSSM commands (rollback reference):**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Status / start / stop / restart
|
# Status / start / stop / restart
|
||||||
nssm status GLAuth
|
nssm status GLAuth
|
||||||
@@ -253,12 +300,12 @@ applies to mxaccessgw verbatim. Keys that change:
|
|||||||
|
|
||||||
| Field | GLAuth dev value | AD production value |
|
| Field | GLAuth dev value | AD production value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Server` | `localhost` | a domain controller FQDN, or the domain itself |
|
| `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
|
||||||
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
||||||
| `UseTls` | `false` | `true` |
|
| `UseTls` | `false` | `true` |
|
||||||
| `AllowInsecureLdap` | `true` | `false` |
|
| `AllowInsecureLdap` | `true` | `false` |
|
||||||
| `SearchBase` | `dc=lmxopcua,dc=local` | `DC=corp,DC=example,DC=com` |
|
| `SearchBase` | `dc=zb,dc=local` | `DC=corp,DC=example,DC=com` |
|
||||||
| `ServiceAccountDn` | `cn=serviceaccount,dc=lmxopcua,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
|
| `ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
|
||||||
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
|
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
|
||||||
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
|
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
|
||||||
|
|
||||||
@@ -269,12 +316,12 @@ add a `tokenGroups` query as an enhancement.
|
|||||||
|
|
||||||
## Security notes for production
|
## Security notes for production
|
||||||
|
|
||||||
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is
|
- **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
|
||||||
unencrypted on disk; anyone with read access to `C:\publish\glauth\`
|
`scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
|
||||||
can SHA256-rainbow-table the entries. Treat the dev creds as
|
`10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
|
||||||
throwaway. Production LDAP is Active Directory.
|
Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
|
||||||
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
- 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]`.
|
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
||||||
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
||||||
wire. Fine for `localhost`, never expose port 3893 off-box without
|
wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
|
||||||
enabling TLS first.
|
expose port 3893 externally without enabling TLS first.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
<!-- nuget.org serves everything; the Gitea feed serves only the ZB.MOM.WW.* shared libs.
|
||||||
|
Credentials are NOT committed: they are provided per-developer at the user level. -->
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="dohertj2-gitea">
|
||||||
|
<package pattern="ZB.MOM.WW.Health" />
|
||||||
|
<package pattern="ZB.MOM.WW.Health.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Configuration" />
|
||||||
|
<package pattern="ZB.MOM.WW.Auth" />
|
||||||
|
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Audit" />
|
||||||
|
<package pattern="ZB.MOM.WW.Theme" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Auth.Ldap;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
||||||
|
|
||||||
@@ -28,12 +31,11 @@ public sealed class DashboardLdapLiveTests
|
|||||||
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
||||||
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
// IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a
|
// IntegrationTests-023: DashboardAuthenticator builds the principal with a
|
||||||
// ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded
|
// ClaimTypes.Role claim resolved from the LDAP groups via the
|
||||||
// GroupToRole map (GwAdmin -> Admin) means the admin principal must
|
// DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
|
||||||
// carry Role=Admin alongside the raw LDAP-group claim. A regression in
|
// means the admin principal must carry Role=Admin alongside the raw LDAP-group
|
||||||
// MapGroupsToRoles (returning an empty list, missing the RDN fallback)
|
// claim. A regression in the group→role mapping would fail this assertion.
|
||||||
// would silently pass without this assertion.
|
|
||||||
Assert.Contains(result.Principal.Claims, claim =>
|
Assert.Contains(result.Principal.Claims, claim =>
|
||||||
claim.Type == ClaimTypes.Role
|
claim.Type == ClaimTypes.Role
|
||||||
&& claim.Value == DashboardRoles.Admin);
|
&& claim.Value == DashboardRoles.Admin);
|
||||||
@@ -59,7 +61,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
||||||
{
|
{
|
||||||
// Exercises the LdapException branch: the user exists and the service
|
// Exercises the user-bind-failure branch: the user exists and the service
|
||||||
// account search succeeds, but the candidate bind is rejected.
|
// account search succeeds, but the candidate bind is rejected.
|
||||||
const string wrongPassword = "definitely-not-the-admin-password";
|
const string wrongPassword = "definitely-not-the-admin-password";
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
@@ -78,8 +80,8 @@ public sealed class DashboardLdapLiveTests
|
|||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
||||||
{
|
{
|
||||||
// Exercises the `candidate is null` branch: the service-account search
|
// Exercises the user-not-found branch: the service-account search returns no
|
||||||
// returns no entry, so no candidate bind is attempted.
|
// entry, so no candidate bind is attempted.
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
@@ -96,18 +98,13 @@ public sealed class DashboardLdapLiveTests
|
|||||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||||
{
|
{
|
||||||
// Exercises the connect-failure path: a closed loopback port produces a
|
// Exercises the connect-failure path: a closed loopback port produces a
|
||||||
// connection error that DashboardAuthenticator must absorb into a Fail
|
// connection error that the shared LdapAuthService must absorb into a Fail
|
||||||
// result rather than propagating an exception to the dashboard.
|
// result rather than propagating an exception to the dashboard.
|
||||||
DashboardAuthenticator authenticator = new(
|
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
{
|
// 1 is a reserved port number that no LDAP server listens on.
|
||||||
Ldap = new LdapOptions
|
Port = 1,
|
||||||
{
|
});
|
||||||
// 1 is a reserved port number that no LDAP server listens on.
|
|
||||||
Port = 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -118,19 +115,48 @@ public sealed class DashboardLdapLiveTests
|
|||||||
Assert.Null(result.Principal);
|
Assert.Null(result.Principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardAuthenticator CreateAuthenticator()
|
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
|
||||||
|
|
||||||
|
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticator(
|
GatewayOptions gatewayOptions = new()
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
{
|
{
|
||||||
Dashboard = new DashboardOptions
|
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
["GwAdmin"] = DashboardRoles.Admin,
|
||||||
{
|
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DashboardAuthenticator(
|
||||||
|
new LdapAuthService(ldapOptions),
|
||||||
|
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
NullLogger<DashboardAuthenticator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the shared library <see cref="LibraryLdapOptions"/> from the gateway's
|
||||||
|
/// default LDAP settings so the live tests exercise the same seeded directory the
|
||||||
|
/// gateway connects to (localhost:3893, plaintext, with AllowInsecure for dev).
|
||||||
|
/// </summary>
|
||||||
|
private static LibraryLdapOptions LibraryOptions()
|
||||||
|
{
|
||||||
|
ZB.MOM.WW.MxGateway.Server.Configuration.LdapOptions gateway = new();
|
||||||
|
return new LibraryLdapOptions
|
||||||
|
{
|
||||||
|
Enabled = gateway.Enabled,
|
||||||
|
Server = gateway.Server,
|
||||||
|
Port = gateway.Port,
|
||||||
|
Transport = gateway.Transport,
|
||||||
|
AllowInsecure = gateway.AllowInsecure,
|
||||||
|
SearchBase = gateway.SearchBase,
|
||||||
|
ServiceAccountDn = gateway.ServiceAccountDn,
|
||||||
|
ServiceAccountPassword = gateway.ServiceAccountPassword,
|
||||||
|
UserNameAttribute = gateway.UserNameAttribute,
|
||||||
|
DisplayNameAttribute = gateway.DisplayNameAttribute,
|
||||||
|
GroupAttribute = gateway.GroupAttribute,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public sealed class DashboardOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequireHttpsCookie { get; init; } = true;
|
public bool RequireHttpsCookie { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dashboard auth cookie name. When null/blank (the default) the canonical
|
||||||
|
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardAuthenticationDefaults.CookieName"/>
|
||||||
|
/// is used. Override it (<c>MxGateway:Dashboard:CookieName</c>) to give a distinct name to a
|
||||||
|
/// gateway that shares a hostname with another gateway instance — browser cookies are scoped
|
||||||
|
/// by host+path but NOT by port, so two instances on the same host would otherwise clobber
|
||||||
|
/// each other's dashboard session under a shared cookie name. Changing this signs out
|
||||||
|
/// existing dashboard sessions on next deploy.
|
||||||
|
/// </summary>
|
||||||
|
public string? CookieName { get; init; }
|
||||||
|
|
||||||
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
||||||
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration(
|
|||||||
bool Enabled,
|
bool Enabled,
|
||||||
string Server,
|
string Server,
|
||||||
int Port,
|
int Port,
|
||||||
bool UseTls,
|
string Transport,
|
||||||
bool AllowInsecureLdap,
|
bool AllowInsecure,
|
||||||
string SearchBase,
|
string SearchBase,
|
||||||
string ServiceAccountDn,
|
string ServiceAccountDn,
|
||||||
string ServiceAccountPassword,
|
string ServiceAccountPassword,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
|||||||
Enabled: value.Ldap.Enabled,
|
Enabled: value.Ldap.Enabled,
|
||||||
Server: value.Ldap.Server,
|
Server: value.Ldap.Server,
|
||||||
Port: value.Ldap.Port,
|
Port: value.Ldap.Port,
|
||||||
UseTls: value.Ldap.UseTls,
|
Transport: value.Ldap.Transport.ToString(),
|
||||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
AllowInsecure: value.Ldap.AllowInsecure,
|
||||||
SearchBase: value.Ldap.SearchBase,
|
SearchBase: value.Ldap.SearchBase,
|
||||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||||
ServiceAccountPassword: RedactedValue,
|
ServiceAccountPassword: RedactedValue,
|
||||||
|
|||||||
+7
-7
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
@@ -6,15 +7,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="configuration">The configuration to bind gateway options from.</param>
|
||||||
/// <returns>The service collection for chaining.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
|
public static IServiceCollection AddGatewayConfiguration(
|
||||||
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services
|
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||||
.AddOptions<GatewayOptions>()
|
configuration, GatewayOptions.SectionName);
|
||||||
.BindConfiguration(GatewayOptions.SectionName)
|
|
||||||
.ValidateOnStart();
|
|
||||||
|
|
||||||
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
|
||||||
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>
|
||||||
{
|
{
|
||||||
private const int MinimumMaxMessageBytes = 1024;
|
private const int MinimumMaxMessageBytes = 1024;
|
||||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||||
@@ -11,33 +12,26 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates gateway configuration options.
|
/// Validates gateway configuration options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="name">Options name.</param>
|
/// <param name="builder">The accumulator to record failures on.</param>
|
||||||
/// <param name="options">Gateway options to validate.</param>
|
/// <param name="options">Gateway options to validate.</param>
|
||||||
/// <returns>Validation result.</returns>
|
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
||||||
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
|
||||||
{
|
{
|
||||||
List<string> failures = [];
|
ValidateAuthentication(options.Authentication, builder);
|
||||||
|
ValidateLdap(options.Ldap, builder);
|
||||||
ValidateAuthentication(options.Authentication, failures);
|
ValidateWorker(options.Worker, builder);
|
||||||
ValidateLdap(options.Ldap, failures);
|
ValidateSessions(options.Sessions, builder);
|
||||||
ValidateWorker(options.Worker, failures);
|
ValidateEvents(options.Events, builder);
|
||||||
ValidateSessions(options.Sessions, failures);
|
ValidateDashboard(options.Dashboard, builder);
|
||||||
ValidateEvents(options.Events, failures);
|
ValidateProtocol(options.Protocol, builder);
|
||||||
ValidateDashboard(options.Dashboard, failures);
|
ValidateAlarms(options.Alarms, builder);
|
||||||
ValidateProtocol(options.Protocol, failures);
|
ValidateTls(options.Tls, builder);
|
||||||
ValidateAlarms(options.Alarms, failures);
|
|
||||||
ValidateTls(options.Tls, failures);
|
|
||||||
|
|
||||||
return failures.Count == 0
|
|
||||||
? ValidateOptionsResult.Success
|
|
||||||
: ValidateOptionsResult.Fail(failures);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!Enum.IsDefined(options.Mode))
|
if (!Enum.IsDefined(options.Mode))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,67 +40,67 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.PepperSecretName,
|
options.PepperSecretName,
|
||||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountDn,
|
options.ServiceAccountDn,
|
||||||
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountPassword,
|
options.ServiceAccountPassword,
|
||||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.UserNameAttribute,
|
options.UserNameAttribute,
|
||||||
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.DisplayNameAttribute,
|
options.DisplayNameAttribute,
|
||||||
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.GroupAttribute,
|
options.GroupAttribute,
|
||||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
builder.Port(options.Port, "MxGateway:Ldap:Port");
|
||||||
|
|
||||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.ExecutablePath,
|
options.ExecutablePath,
|
||||||
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
||||||
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
||||||
@@ -114,94 +108,94 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.WorkingDirectory,
|
options.WorkingDirectory,
|
||||||
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.RequiredArchitecture))
|
if (!Enum.IsDefined(options.RequiredArchitecture))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupTimeoutSeconds,
|
options.StartupTimeoutSeconds,
|
||||||
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryAttempts,
|
options.StartupProbeRetryAttempts,
|
||||||
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryDelayMilliseconds,
|
options.StartupProbeRetryDelayMilliseconds,
|
||||||
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.PipeConnectAttemptTimeoutMilliseconds,
|
options.PipeConnectAttemptTimeoutMilliseconds,
|
||||||
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.ShutdownTimeoutSeconds,
|
options.ShutdownTimeoutSeconds,
|
||||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatIntervalSeconds,
|
options.HeartbeatIntervalSeconds,
|
||||||
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatGraceSeconds,
|
options.HeartbeatGraceSeconds,
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
private static void ValidateSessions(SessionOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultCommandTimeoutSeconds,
|
options.DefaultCommandTimeoutSeconds,
|
||||||
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.MaxPendingCommandsPerSession,
|
options.MaxPendingCommandsPerSession,
|
||||||
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultLeaseSeconds,
|
options.DefaultLeaseSeconds,
|
||||||
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.LeaseSweepIntervalSeconds,
|
options.LeaseSweepIntervalSeconds,
|
||||||
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.AllowMultipleEventSubscribers)
|
if (options.AllowMultipleEventSubscribers)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateEvents(EventOptions options, List<string> failures)
|
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
// GroupToRole shape is validated even when the dashboard is disabled so
|
// GroupToRole shape is validated even when the dashboard is disabled so
|
||||||
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
||||||
@@ -212,13 +206,13 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(entry.Key))
|
if (string.IsNullOrWhiteSpace(entry.Key))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
||||||
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,18 +220,18 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.SnapshotIntervalMilliseconds,
|
options.SnapshotIntervalMilliseconds,
|
||||||
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentFaultLimit,
|
options.RecentFaultLimit,
|
||||||
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentSessionLimit,
|
options.RecentSessionLimit,
|
||||||
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, List<string> failures)
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
@@ -251,14 +245,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,11 +260,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
private const int MinimumCertValidityYears = 1;
|
private const int MinimumCertValidityYears = 1;
|
||||||
private const int MaximumCertValidityYears = 100;
|
private const int MaximumCertValidityYears = 100;
|
||||||
|
|
||||||
private static void ValidateTls(TlsOptions options, List<string> failures)
|
private static void ValidateTls(TlsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
|
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,61 +272,52 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.SelfSignedCertPath,
|
options.SelfSignedCertPath,
|
||||||
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
|
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
|
||||||
failures);
|
builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.SelfSignedCertPath,
|
options.SelfSignedCertPath,
|
||||||
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
foreach (string dns in options.AdditionalDnsNames)
|
foreach (string dns in options.AdditionalDnsNames)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dns))
|
if (string.IsNullOrWhiteSpace(dns))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
private static void AddIfNotPositive(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value <= 0)
|
builder.RequireThat(value > 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNegative(int value, string message, List<string> failures)
|
private static void AddIfNegative(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value < 0)
|
builder.RequireThat(value >= 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
@@ -345,15 +330,19 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (NotSupportedException)
|
catch (NotSupportedException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (PathTooLongException)
|
catch (PathTooLongException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gateway-side view of the <c>MxGateway:Ldap</c> section. This is a SHADOW of the
|
||||||
|
/// shared <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> type and is NOT
|
||||||
|
/// used to perform LDAP authentication at runtime — runtime bind/search is done by the
|
||||||
|
/// shared <c>ZB.MOM.WW.Auth.Ldap</c> provider, whose options are bound directly from the
|
||||||
|
/// same <c>MxGateway:Ldap</c> section by <c>AddZbLdapAuth</c> (see
|
||||||
|
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardServiceCollectionExtensions"/>).
|
||||||
|
/// <para>
|
||||||
|
/// This shadow exists for three things only: (1) startup validation via
|
||||||
|
/// <see cref="GatewayOptionsValidator"/>; (2) the redacted effective-config display
|
||||||
|
/// (<see cref="EffectiveLdapConfiguration"/> / <see cref="GatewayConfigurationProvider"/>);
|
||||||
|
/// and (3) it is the single home of the gateway's dev/default LDAP values, which the
|
||||||
|
/// integration live-test helper copies onto the shared options.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Review C2 — DRIFT WARNING: this class MUST stay field-compatible with the shared
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> so the one config section
|
||||||
|
/// binds cleanly onto both. The two are intentionally NOT merged because their defaults
|
||||||
|
/// differ on purpose: this shadow ships dev-friendly defaults (plaintext localhost,
|
||||||
|
/// <c>AllowInsecure=true</c>, populated <c>SearchBase</c>/<c>ServiceAccount*</c>), whereas
|
||||||
|
/// the shared type is secure-by-default (<c>Transport=Ldaps</c>, <c>AllowInsecure=false</c>,
|
||||||
|
/// empty DN fields). If you add/rename/remove a field on the shared type, mirror it here
|
||||||
|
/// (and in the validator + effective-config) so the section keeps binding to both.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public sealed class LdapOptions
|
public sealed class LdapOptions
|
||||||
{
|
{
|
||||||
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
||||||
@@ -11,17 +38,24 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Gets the LDAP server port.</summary>
|
/// <summary>Gets the LDAP server port.</summary>
|
||||||
public int Port { get; init; } = 3893;
|
public int Port { get; init; } = 3893;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
|
/// <summary>
|
||||||
public bool UseTls { get; init; }
|
/// Gets the transport/TLS mode for the LDAP connection. Replaces the former
|
||||||
|
/// boolean <c>UseTls</c> (true ≈ <see cref="LdapTransport.Ldaps"/>, false =
|
||||||
|
/// <see cref="LdapTransport.None"/>). <see cref="LdapTransport.StartTls"/> upgrades
|
||||||
|
/// a plaintext connection to TLS. Matches the shared
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.Transport"/> field so the
|
||||||
|
/// <c>MxGateway:Ldap</c> section binds straight onto the shared options.
|
||||||
|
/// </summary>
|
||||||
|
public LdapTransport Transport { get; init; } = LdapTransport.None;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
|
/// <summary>Gets a value indicating whether insecure (plaintext) LDAP connections are allowed.</summary>
|
||||||
public bool AllowInsecureLdap { get; init; } = true;
|
public bool AllowInsecure { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
||||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
public string SearchBase { get; init; } = "dc=zb,dc=local";
|
||||||
|
|
||||||
/// <summary>Gets the service account distinguished name.</summary>
|
/// <summary>Gets the service account distinguished name.</summary>
|
||||||
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local";
|
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local";
|
||||||
|
|
||||||
/// <summary>Gets the service account password.</summary>
|
/// <summary>Gets the service account password.</summary>
|
||||||
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<ThemeHead />
|
||||||
<link rel="stylesheet" href="/css/site.css" />
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
<HeadOutlet @rendermode="InteractiveServer" />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-body">
|
<body class="dashboard-body">
|
||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/js/nav-state.js"></script>
|
<ThemeScripts />
|
||||||
<script src="/_framework/blazor.web.js"></script>
|
<script src="/_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@* Minimal layout for the login page: no side rail, no brand block. The page
|
||||||
|
renders its own centred card via the shared kit's <LoginCard>. Mirrors
|
||||||
|
OtOpcUa AdminUI's LoginLayout. *@
|
||||||
|
@Body
|
||||||
@@ -1,210 +1,40 @@
|
|||||||
@using System.Linq
|
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
|
||||||
@using Microsoft.JSInterop
|
|
||||||
@implements IDisposable
|
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
|
|
||||||
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
|
@* Thin layout: delegates the side-rail chassis (hamburger, brand, responsive
|
||||||
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS
|
collapse) to the shared ZB.MOM.WW.Theme <ThemeShell>. The nav is reproduced
|
||||||
lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
with the kit's NavRailSection / NavRailItem; section expand-state persistence
|
||||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
is owned by the kit's <details> + ThemeScripts (no JS interop here). *@
|
||||||
type="button"
|
<ThemeShell Product="MXAccess Gateway" Accent="#2f5fd0">
|
||||||
data-bs-toggle="collapse"
|
<Nav>
|
||||||
data-bs-target="#sidebar-collapse"
|
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
|
||||||
aria-controls="sidebar-collapse"
|
<NavRailSection Title="Runtime" Key="runtime">
|
||||||
aria-expanded="false"
|
<NavRailItem Href="/sessions" Text="Sessions" />
|
||||||
aria-label="Toggle navigation">
|
<NavRailItem Href="/workers" Text="Workers" />
|
||||||
☰
|
<NavRailItem Href="/events" Text="Events" />
|
||||||
</button>
|
<NavRailItem Href="/alarms" Text="Alarms" />
|
||||||
|
</NavRailSection>
|
||||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
<NavRailSection Title="Galaxy" Key="galaxy">
|
||||||
<nav class="sidebar d-flex flex-column">
|
<NavRailItem Href="/galaxy" Text="Repository" />
|
||||||
<a class="brand" href="/"><span class="mark">▮</span> MXAccess Gateway</a>
|
<NavRailItem Href="/browse" Text="Browse" />
|
||||||
|
</NavRailSection>
|
||||||
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
<NavRailSection Title="Admin" Key="admin">
|
||||||
<ul class="nav flex-column">
|
<NavRailItem Href="/apikeys" Text="API Keys" />
|
||||||
<li class="nav-item">
|
<NavRailItem Href="/settings" Text="Settings" />
|
||||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
</NavRailSection>
|
||||||
</li>
|
</Nav>
|
||||||
|
<RailFooter>
|
||||||
<NavSection Title="Runtime"
|
<AuthorizeView>
|
||||||
Expanded="@_expanded.Contains("runtime")"
|
<Authorized Context="authState">
|
||||||
OnToggle="@(() => ToggleAsync("runtime"))">
|
<span class="rail-user">@authState.User.Identity?.Name</span>
|
||||||
<li class="nav-item">
|
<form method="post" action="/logout" data-enhance="false">
|
||||||
<NavLink class="nav-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
|
<AntiforgeryToken />
|
||||||
</li>
|
<button class="rail-btn" type="submit">Sign Out</button>
|
||||||
<li class="nav-item">
|
</form>
|
||||||
<NavLink class="nav-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
|
</Authorized>
|
||||||
</li>
|
<NotAuthorized>
|
||||||
<li class="nav-item">
|
<a class="rail-btn" href="/login">Sign In</a>
|
||||||
<NavLink class="nav-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
|
</NotAuthorized>
|
||||||
</li>
|
</AuthorizeView>
|
||||||
<li class="nav-item">
|
</RailFooter>
|
||||||
<NavLink class="nav-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
|
<ChildContent>@Body</ChildContent>
|
||||||
</li>
|
</ThemeShell>
|
||||||
</NavSection>
|
|
||||||
|
|
||||||
<NavSection Title="Galaxy"
|
|
||||||
Expanded="@_expanded.Contains("galaxy")"
|
|
||||||
OnToggle="@(() => ToggleAsync("galaxy"))">
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
|
|
||||||
</li>
|
|
||||||
</NavSection>
|
|
||||||
|
|
||||||
<NavSection Title="Admin"
|
|
||||||
Expanded="@_expanded.Contains("admin")"
|
|
||||||
OnToggle="@(() => ToggleAsync("admin"))">
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/apikeys" Match="NavLinkMatch.Prefix">API Keys</NavLink>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
|
|
||||||
</li>
|
|
||||||
</NavSection>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized Context="authState">
|
|
||||||
<div class="border-top px-3 py-2">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-body-secondary small">@authState.User.Identity?.Name</span>
|
|
||||||
<form method="post" action="/logout" data-enhance="false">
|
|
||||||
<AntiforgeryToken />
|
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
<div class="border-top px-3 py-2">
|
|
||||||
<a href="/login" class="btn btn-outline-secondary btn-sm py-0 px-2 w-100">Sign In</a>
|
|
||||||
</div>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="page flex-grow-1">
|
|
||||||
@Body
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
// Sections whose collapsed/expanded state we persist. Acts as the allow-list
|
|
||||||
// when parsing the cookie so stale or attacker-supplied ids are ignored.
|
|
||||||
private static readonly string[] SectionIds = { "runtime", "galaxy", "admin" };
|
|
||||||
|
|
||||||
// The currently-expanded sections. Populated from the cookie on first
|
|
||||||
// render; mutated by ToggleAsync and by navigating into a section.
|
|
||||||
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged += OnLocationChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (!firstRender)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hydrate from the cookie. Until this completes the sidebar paints
|
|
||||||
// collapsed, matching the CentralUI behaviour.
|
|
||||||
string saved;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var id in saved.Split(
|
|
||||||
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
||||||
{
|
|
||||||
if (Array.IndexOf(SectionIds, id) >= 0)
|
|
||||||
{
|
|
||||||
_expanded.Add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The section of the page we loaded on is always expanded.
|
|
||||||
if (EnsureCurrentSectionExpanded())
|
|
||||||
{
|
|
||||||
await PersistAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (EnsureCurrentSectionExpanded())
|
|
||||||
{
|
|
||||||
_ = PersistAsync();
|
|
||||||
_ = InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ToggleAsync(string id)
|
|
||||||
{
|
|
||||||
if (!_expanded.Remove(id))
|
|
||||||
{
|
|
||||||
_expanded.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await PersistAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds the current page's section to _expanded; returns true if it changed.
|
|
||||||
private bool EnsureCurrentSectionExpanded()
|
|
||||||
{
|
|
||||||
var section = CurrentSection();
|
|
||||||
return section is not null && _expanded.Add(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps the current URL's first path segment to a section id, or null for
|
|
||||||
// sectionless pages (Dashboard, Login).
|
|
||||||
private string? CurrentSection()
|
|
||||||
{
|
|
||||||
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
|
||||||
var firstSegment = relative.Split('?', '#')[0]
|
|
||||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
return firstSegment switch
|
|
||||||
{
|
|
||||||
"sessions" or "workers" or "events" or "alarms" => "runtime",
|
|
||||||
"galaxy" or "browse" => "galaxy",
|
|
||||||
"apikeys" or "settings" => "admin",
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PersistAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
// The circuit is gone — nothing to persist to.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged -= OnLocationChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
@* A collapsible sidebar nav section. The header is a full-width button that
|
|
||||||
toggles ChildContent visibility. Pattern lifted from ScadaLink CentralUI
|
|
||||||
(Components/Layout/NavSection.razor) — see [[project-deployed-service]]. *@
|
|
||||||
|
|
||||||
<li class="nav-item">
|
|
||||||
<button type="button"
|
|
||||||
class="nav-section-toggle"
|
|
||||||
@onclick="OnToggle"
|
|
||||||
aria-expanded="@(Expanded ? "true" : "false")">
|
|
||||||
<span class="chevron" aria-hidden="true">@(Expanded ? "▾" : "▸")</span>
|
|
||||||
<span>@Title</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
@if (Expanded)
|
|
||||||
{
|
|
||||||
@ChildContent
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
/// <summary>Section label shown in the header (e.g. "Runtime").</summary>
|
|
||||||
[Parameter, EditorRequired]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Whether the section is expanded — its items rendered.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public bool Expanded { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Raised when the header button is clicked.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback OnToggle { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The section's nav items, rendered only while expanded.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public RenderFragment? ChildContent { get; set; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@* Login MUST stay anonymously reachable — [AllowAnonymous] overrides the
|
||||||
|
RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies, so the
|
||||||
|
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
||||||
|
|
||||||
|
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
||||||
|
<form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
|
||||||
|
form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
|
||||||
|
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
||||||
|
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
|
||||||
|
|
||||||
|
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
|
||||||
|
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
|
||||||
|
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
|
||||||
|
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
|
||||||
|
sharing a route. *@
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
|
||||||
|
<LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
</LoginCard>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Original protected URL the operator was bounced from; round-tripped to POST /login.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||||
|
private string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Failure message surfaced by POST /login after a failed authentication.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
|
private string? Error { get; set; }
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ else
|
|||||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
||||||
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
<tr><th scope="row">LDAP transport</th><td>@Snapshot.Configuration.Ldap.Transport</td></tr>
|
||||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<span class="chip @CssClass">@Text</span>
|
@* Thin adapter: maps MxGateway runtime state text → kit StatusPill state.
|
||||||
|
The bespoke .chip rendering now lives in the kit; only the app's domain
|
||||||
|
text→state vocabulary remains here. Call sites (<StatusBadge Text="..."/>) unchanged. *@
|
||||||
|
<StatusPill State="MapState(Text)">@Text</StatusPill>
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter] public string? Text { get; set; }
|
||||||
public string? Text { get; set; }
|
|
||||||
|
|
||||||
private string CssClass => Text switch
|
private static StatusState MapState(string? text) => text switch
|
||||||
{
|
{
|
||||||
"Ready" or "Healthy" or "Active" => "chip-ok",
|
"Ready" or "Healthy" or "Active" => StatusState.Ok,
|
||||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
|
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing"
|
||||||
"Stale" or "Degraded" => "chip-warn",
|
or "Stale" or "Degraded" => StatusState.Warn,
|
||||||
"Faulted" or "Unavailable" => "chip-bad",
|
"Faulted" or "Unavailable" => StatusState.Bad,
|
||||||
"Closed" or "Revoked" or "Unknown" => "chip-idle",
|
_ => StatusState.Idle,
|
||||||
_ => "chip-idle"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@
|
|||||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Workers
|
@using ZB.MOM.WW.MxGateway.Server.Workers
|
||||||
|
@using ZB.MOM.WW.Theme
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
@@ -7,12 +12,13 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
|
|
||||||
public sealed class DashboardApiKeyManagementService(
|
public sealed class DashboardApiKeyManagementService(
|
||||||
DashboardApiKeyAuthorization authorization,
|
DashboardApiKeyAuthorization authorization,
|
||||||
|
ApiKeyAdminCommands adminCommands,
|
||||||
IApiKeyAdminStore adminStore,
|
IApiKeyAdminStore adminStore,
|
||||||
IApiKeyAuditStore auditStore,
|
IAuditWriter auditWriter,
|
||||||
IApiKeySecretHasher hasher,
|
|
||||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||||
{
|
{
|
||||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||||
|
private const string PepperUnavailableMarker = "pepper unavailable";
|
||||||
|
|
||||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
/// <summary>Determines whether the user can manage API keys.</summary>
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
/// <param name="user">The authenticated user principal.</param>
|
||||||
@@ -42,28 +48,31 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string keyId = request.KeyId.Trim();
|
string keyId = request.KeyId.Trim();
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
|
||||||
string apiKey = FormatApiKey(keyId, secret);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await adminStore.CreateAsync(
|
// The shared command set generates the secret, hashes it with the pepper, persists the
|
||||||
new ApiKeyCreateRequest(
|
// record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own
|
||||||
KeyId: keyId,
|
// "create-key" audit entry (now canonicalized through the IApiKeyAuditStore->IAuditWriter
|
||||||
KeyPrefix: $"mxgw_{keyId}",
|
// adapter); the dashboard layers a richer "dashboard-create-key" canonical AuditEvent
|
||||||
SecretHash: hasher.HashSecret(secret),
|
// (Target + CorrelationId + remote address) on top via IAuditWriter to preserve the
|
||||||
DisplayName: request.DisplayName.Trim(),
|
// dashboard audit vocabulary — both rows land in the canonical audit_event store.
|
||||||
Scopes: request.Scopes,
|
CreateKeyResult created = await adminCommands.CreateKeyAsync(
|
||||||
Constraints: request.Constraints,
|
keyId,
|
||||||
CreatedUtc: DateTimeOffset.UtcNow),
|
request.DisplayName.Trim(),
|
||||||
|
request.Scopes,
|
||||||
|
ApiKeyConstraintSerializer.Serialize(request.Constraints),
|
||||||
|
RemoteAddress(),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
await WriteDashboardAuditAsync(user, 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);
|
return DashboardApiKeyManagementResult.Success(
|
||||||
|
"API key created. Copy the key now; it will not be shown again.",
|
||||||
|
created.Token);
|
||||||
}
|
}
|
||||||
catch (ApiKeyPepperUnavailableException)
|
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
|
||||||
{
|
{
|
||||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||||
}
|
}
|
||||||
@@ -94,18 +103,19 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string normalizedKeyId = keyId.Trim();
|
string normalizedKeyId = keyId.Trim();
|
||||||
bool revoked = await adminStore
|
KeyActionResult result = await adminCommands
|
||||||
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
|
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-revoke-key",
|
"dashboard-revoke-key",
|
||||||
revoked ? "revoked" : "not-found-or-already-revoked",
|
result.Succeeded ? "revoked" : "not-found-or-already-revoked",
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return revoked
|
return result.Succeeded
|
||||||
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
||||||
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
||||||
}
|
}
|
||||||
@@ -131,27 +141,30 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string normalizedKeyId = keyId.Trim();
|
string normalizedKeyId = keyId.Trim();
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
|
||||||
string apiKey = FormatApiKey(normalizedKeyId, secret);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
bool rotated = await adminStore
|
CreateKeyResult rotated = await adminCommands
|
||||||
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
bool succeeded = rotated.Token is not null;
|
||||||
|
|
||||||
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-rotate-key",
|
"dashboard-rotate-key",
|
||||||
rotated ? "rotated" : "not-found",
|
succeeded ? "rotated" : "not-found",
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return rotated
|
return succeeded
|
||||||
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
|
? DashboardApiKeyManagementResult.Success(
|
||||||
|
"API key rotated. Copy the key now; it will not be shown again.",
|
||||||
|
rotated.Token)
|
||||||
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
||||||
}
|
}
|
||||||
catch (ApiKeyPepperUnavailableException)
|
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
|
||||||
{
|
{
|
||||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||||
}
|
}
|
||||||
@@ -182,7 +195,8 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.DeleteAsync(normalizedKeyId, cancellationToken)
|
.DeleteAsync(normalizedKeyId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-delete-key",
|
"dashboard-delete-key",
|
||||||
deleted ? "deleted" : "not-found-or-active",
|
deleted ? "deleted" : "not-found-or-active",
|
||||||
@@ -194,22 +208,92 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AppendAuditAsync(
|
private string? RemoteAddress() =>
|
||||||
string? keyId,
|
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||||
string eventType,
|
|
||||||
string? details,
|
/// <summary>
|
||||||
|
/// Resolves the operator's username from the authenticated dashboard principal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The passed <paramref name="user"/> is preferred over the ambient HTTP context because it
|
||||||
|
/// is already in scope at every call site (the callers gate on <see cref="CanManage"/> using
|
||||||
|
/// it) and is unambiguous. Falls back to <see cref="IAuditActorAccessor.CurrentActor"/> for
|
||||||
|
/// defensive coverage, then to <c>"unknown"</c> when neither is available.
|
||||||
|
/// </remarks>
|
||||||
|
private static string ResolveOperatorActor(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
// ZbClaimTypes.Username = "zb:username" — the canonical LDAP login name.
|
||||||
|
string? username = user.FindFirstValue(ZB.MOM.WW.Auth.AspNetCore.ZbClaimTypes.Username);
|
||||||
|
if (!string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framework fallback: Identity.Name is driven by the nameClaimType on the ClaimsIdentity
|
||||||
|
// (set to ZbClaimTypes.Name = ClaimTypes.Name by DashboardAuthenticator → display name).
|
||||||
|
string? identityName = user.Identity?.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(identityName))
|
||||||
|
{
|
||||||
|
return identityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
|
||||||
|
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
|
||||||
|
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
|
||||||
|
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
|
||||||
|
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Phase 3 (Actor = operator principal): <c>Actor</c> is the LDAP operator who performed the
|
||||||
|
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
|
||||||
|
/// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task WriteDashboardAuditAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string keyId,
|
||||||
|
string action,
|
||||||
|
string? detail,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await auditStore.AppendAsync(
|
AuditEvent auditEvent = new()
|
||||||
new ApiKeyAuditEntry(
|
{
|
||||||
KeyId: keyId,
|
EventId = Guid.NewGuid(),
|
||||||
EventType: eventType,
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
|
Actor = ResolveOperatorActor(user),
|
||||||
Details: details),
|
Action = action,
|
||||||
cancellationToken)
|
Outcome = AuditOutcome.Success,
|
||||||
.ConfigureAwait(false);
|
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
|
||||||
|
Target = keyId,
|
||||||
|
SourceNode = RemoteAddress(),
|
||||||
|
CorrelationId = ParseCorrelationId(),
|
||||||
|
DetailsJson = WrapDetail(detail),
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a correlation id from the ASP.NET Core request trace identifier when it is a
|
||||||
|
/// well-formed GUID; otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the
|
||||||
|
/// connection:request form, not a GUID, so it correlates to null rather than fabricating one).
|
||||||
|
/// </summary>
|
||||||
|
private Guid? ParseCorrelationId() =>
|
||||||
|
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
|
||||||
|
? correlationId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? WrapDetail(string? detail) =>
|
||||||
|
detail is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
|
||||||
|
|
||||||
|
private static bool IsPepperUnavailable(InvalidOperationException exception) =>
|
||||||
|
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
||||||
{
|
{
|
||||||
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
||||||
@@ -248,9 +332,4 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
? null
|
? null
|
||||||
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatApiKey(string keyId, string secret)
|
|
||||||
{
|
|
||||||
return $"mxgw_{keyId}_{secret}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
||||||
using Novell.Directory.Ldap;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates interactive dashboard logins against LDAP. The bind/search
|
||||||
|
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
|
||||||
|
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), which performs bind-then-search, fails closed,
|
||||||
|
/// and never throws — returning the user's display name and LDAP groups on
|
||||||
|
/// success. This class keeps the dashboard-specific policy: groups are resolved
|
||||||
|
/// to dashboard roles via <see cref="IGroupRoleMapper{TRole}"/>, a login with no
|
||||||
|
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
|
||||||
|
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
|
||||||
|
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
|
||||||
|
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||||
public sealed class DashboardAuthenticator(
|
public sealed class DashboardAuthenticator(
|
||||||
IOptions<GatewayOptions> options,
|
ILdapAuthService ldapAuthService,
|
||||||
|
IGroupRoleMapper<string> roleMapper,
|
||||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||||
{
|
{
|
||||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
||||||
@@ -19,240 +31,72 @@ public sealed class DashboardAuthenticator(
|
|||||||
string? password,
|
string? password,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
LdapOptions ldapOptions = options.Value.Ldap;
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
DashboardOptions dashboardOptions = options.Value.Dashboard;
|
|
||||||
if (!ldapOptions.Enabled
|
|
||||||
|| string.IsNullOrWhiteSpace(username)
|
|
||||||
|| string.IsNullOrWhiteSpace(password))
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
|
||||||
{
|
{
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
string normalizedUsername = username.Trim();
|
string normalizedUsername = username.Trim();
|
||||||
|
|
||||||
try
|
// The shared service owns connect/bind/search and the fail-closed contract:
|
||||||
|
// it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
|
||||||
|
// its startup validator, and never throws. We only translate its outcome into a
|
||||||
|
// dashboard principal here.
|
||||||
|
LdapAuthResult ldapResult = await ldapAuthService
|
||||||
|
.AuthenticateAsync(normalizedUsername, password, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!ldapResult.Succeeded)
|
||||||
{
|
{
|
||||||
using LdapConnection connection = new();
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
connection.SecureSocketLayer = ldapOptions.UseTls;
|
|
||||||
|
|
||||||
await Task.Run(
|
|
||||||
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
|
||||||
LdapEntry? candidate = await SearchUserAsync(
|
|
||||||
connection,
|
|
||||||
ldapOptions,
|
|
||||||
normalizedUsername,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (candidate is null)
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Run(
|
|
||||||
() => connection.Bind(candidate.Dn, password),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
|
||||||
LdapEntry? authenticatedEntry = await SearchUserAsync(
|
|
||||||
connection,
|
|
||||||
ldapOptions,
|
|
||||||
normalizedUsername,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (authenticatedEntry is null)
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
|
|
||||||
?? normalizedUsername;
|
|
||||||
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
|
||||||
|
|
||||||
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
|
|
||||||
if (roles.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation(
|
|
||||||
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
|
||||||
normalizedUsername);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
|
||||||
normalizedUsername,
|
|
||||||
displayName,
|
|
||||||
groups,
|
|
||||||
roles));
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
GroupRoleMapping<string> mapping = await roleMapper
|
||||||
throw;
|
.MapAsync(ldapResult.Groups, cancellationToken)
|
||||||
}
|
.ConfigureAwait(false);
|
||||||
catch (LdapException ex)
|
|
||||||
|
IReadOnlyList<string> roles = mapping.Roles;
|
||||||
|
if (roles.Count == 0)
|
||||||
{
|
{
|
||||||
|
// Preserve the long-standing "no roles matched -> login denied" rule.
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
||||||
normalizedUsername,
|
ldapResult.Username);
|
||||||
ex.ResultCode);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||||
}
|
ldapResult.Username,
|
||||||
}
|
ldapResult.DisplayName,
|
||||||
|
ldapResult.Groups,
|
||||||
/// <summary>Escapes special characters in LDAP filter strings.</summary>
|
roles));
|
||||||
/// <param name="value">The string value to escape.</param>
|
|
||||||
internal static string EscapeLdapFilter(string value)
|
|
||||||
{
|
|
||||||
StringBuilder builder = new(value.Length);
|
|
||||||
foreach (char character in value)
|
|
||||||
{
|
|
||||||
builder.Append(character switch
|
|
||||||
{
|
|
||||||
'\\' => @"\5c",
|
|
||||||
'*' => @"\2a",
|
|
||||||
'(' => @"\28",
|
|
||||||
')' => @"\29",
|
|
||||||
'\0' => @"\00",
|
|
||||||
_ => character.ToString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
|
||||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
|
||||||
/// an empty list when no group matches (caller rejects the login).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
/// <param name="username">
|
||||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
/// The (trimmed) login name. Emitted as <see cref="ClaimTypes.NameIdentifier"/> (kept for
|
||||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
/// back-compat reads) and as the canonical <see cref="ZbClaimTypes.Username"/> ("zb:username").
|
||||||
IEnumerable<string> groups,
|
/// </param>
|
||||||
IReadOnlyDictionary<string, string> groupToRole)
|
/// <param name="displayName">
|
||||||
{
|
/// The user's display name. Emitted as <see cref="ZbClaimTypes.Name"/> (= <see cref="ClaimTypes.Name"/>
|
||||||
if (groupToRole.Count == 0)
|
/// so <c>Identity.Name</c> resolves) and as <see cref="ZbClaimTypes.DisplayName"/> ("zb:displayname")
|
||||||
{
|
/// for cross-app consistency.
|
||||||
return [];
|
/// </param>
|
||||||
}
|
/// <param name="groups">
|
||||||
|
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
|
||||||
HashSet<string> roles = new(StringComparer.Ordinal);
|
/// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
|
||||||
foreach (string group in groups)
|
/// <c>GwAdmin</c>), not raw distinguished names. The shared
|
||||||
{
|
/// <c>ZB.MOM.WW.Auth.Ldap</c> provider strips each group DN to its first RDN
|
||||||
string normalizedGroup = group.Trim();
|
/// value before returning it, so the <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/>
|
||||||
|
/// claim carries the short name. This differs from the pre-cutover behaviour,
|
||||||
// Lookup precedence (Server-040): the full literal group string is
|
/// which surfaced the raw <c>memberOf</c> values (full DNs) on the claim; the
|
||||||
// tried first; only if that misses do we fall back to the leading
|
/// claim is informational only (no policy or UI reads its value — authorization
|
||||||
// RDN value (e.g. "GwAdmin" extracted from
|
/// is role-based), so the shape change is non-breaking for dashboard consumers.
|
||||||
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
/// </param>
|
||||||
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
|
||||||
// "GwAdmin" and "gwadmin" both match.
|
|
||||||
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
|
||||||
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
|
||||||
{
|
|
||||||
roles.Add(mapped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [.. roles];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
|
||||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
|
||||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
|
||||||
{
|
|
||||||
int equalsIndex = distinguishedName.IndexOf('=');
|
|
||||||
if (equalsIndex < 0)
|
|
||||||
{
|
|
||||||
return distinguishedName;
|
|
||||||
}
|
|
||||||
|
|
||||||
int valueStart = equalsIndex + 1;
|
|
||||||
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
|
||||||
|
|
||||||
return commaIndex > valueStart
|
|
||||||
? distinguishedName[valueStart..commaIndex]
|
|
||||||
: distinguishedName[valueStart..];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task BindServiceAccountAsync(
|
|
||||||
LdapConnection connection,
|
|
||||||
LdapOptions ldapOptions,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.Run(
|
|
||||||
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<LdapEntry?> SearchUserAsync(
|
|
||||||
LdapConnection connection,
|
|
||||||
LdapOptions ldapOptions,
|
|
||||||
string username,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
|
|
||||||
ILdapSearchResults results = await Task.Run(
|
|
||||||
() => connection.Search(
|
|
||||||
ldapOptions.SearchBase,
|
|
||||||
LdapConnection.ScopeSub,
|
|
||||||
filter,
|
|
||||||
attrs: null,
|
|
||||||
typesOnly: false),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
LdapEntry? entry = null;
|
|
||||||
while (results.HasMore())
|
|
||||||
{
|
|
||||||
LdapEntry next = results.Next();
|
|
||||||
if (entry is not null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
|
||||||
{
|
|
||||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
|
||||||
{
|
|
||||||
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
|
||||||
return attribute?.StringValueArray ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
|
||||||
{
|
|
||||||
return entry.GetAttribute(attributeName)
|
|
||||||
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
|
||||||
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ClaimsPrincipal CreatePrincipal(
|
private static ClaimsPrincipal CreatePrincipal(
|
||||||
string username,
|
string username,
|
||||||
string displayName,
|
string displayName,
|
||||||
@@ -261,11 +105,21 @@ public sealed class DashboardAuthenticator(
|
|||||||
{
|
{
|
||||||
List<Claim> claims =
|
List<Claim> claims =
|
||||||
[
|
[
|
||||||
|
// Keep NameIdentifier so any existing read-site that uses it continues to work.
|
||||||
new Claim(ClaimTypes.NameIdentifier, username),
|
new Claim(ClaimTypes.NameIdentifier, username),
|
||||||
new Claim(ClaimTypes.Name, displayName),
|
// Canonical login-username claim (Task 1.5).
|
||||||
|
new Claim(ZbClaimTypes.Username, username),
|
||||||
|
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
|
||||||
|
new Claim(ZbClaimTypes.Name, displayName),
|
||||||
|
// Canonical display-name claim for cross-app consistency (Task 1.5).
|
||||||
|
new Claim(ZbClaimTypes.DisplayName, displayName),
|
||||||
];
|
];
|
||||||
|
|
||||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
// ZbClaimTypes.Role == ClaimTypes.Role — drives IsInRole and [Authorize(Roles=...)].
|
||||||
|
claims.AddRange(roles.Select(role => new Claim(ZbClaimTypes.Role, role)));
|
||||||
|
// Groups are short RDN names from ILdapAuthService (see param doc above), so
|
||||||
|
// this claim value is the short group name, not the original DN.
|
||||||
|
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
|
||||||
claims.AddRange(groups.Select(group => new Claim(
|
claims.AddRange(groups.Select(group => new Claim(
|
||||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||||
group)));
|
group)));
|
||||||
@@ -273,8 +127,8 @@ public sealed class DashboardAuthenticator(
|
|||||||
ClaimsIdentity claimsIdentity = new(
|
ClaimsIdentity claimsIdentity = new(
|
||||||
claims,
|
claims,
|
||||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
ClaimTypes.Name,
|
ZbClaimTypes.Name,
|
||||||
ClaimTypes.Role);
|
ZbClaimTypes.Role);
|
||||||
|
|
||||||
return new ClaimsPrincipal(claimsIdentity);
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-60
@@ -1,7 +1,6 @@
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
@@ -25,14 +24,19 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints.MapGet(
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
||||||
"/login",
|
// (Components/Pages/Login.razor → @page "/login"), which renders the shared
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery))
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
||||||
.AllowAnonymous()
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
||||||
.WithName("DashboardLogin");
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
||||||
|
//
|
||||||
|
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
|
||||||
|
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
|
||||||
|
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
|
||||||
|
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
|
||||||
|
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
"/login",
|
"/auth/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
@@ -92,17 +96,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<ContentHttpResult> GetLoginAsync(
|
|
||||||
HttpContext httpContext,
|
|
||||||
IAntiforgery antiforgery)
|
|
||||||
{
|
|
||||||
string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString());
|
|
||||||
|
|
||||||
return Task.FromResult(TypedResults.Content(
|
|
||||||
RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null),
|
|
||||||
"text/html"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> PostLoginAsync(
|
private static async Task<IResult> PostLoginAsync(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
IAntiforgery antiforgery,
|
IAntiforgery antiforgery,
|
||||||
@@ -124,10 +117,13 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
|
|
||||||
if (!result.Succeeded || result.Principal is null)
|
if (!result.Succeeded || result.Principal is null)
|
||||||
{
|
{
|
||||||
return TypedResults.Content(
|
// Round-trip the failure back to the anonymous Blazor /login page, carrying
|
||||||
RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage),
|
// the (sanitized) returnUrl so a successful retry still lands on the target.
|
||||||
"text/html",
|
string failureMessage = result.FailureMessage
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
?? "The username or password is invalid, or the user is not authorized.";
|
||||||
|
return Results.Redirect(
|
||||||
|
$"/login?error={Uri.EscapeDataString(failureMessage)}"
|
||||||
|
+ $"&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await httpContext
|
await httpContext
|
||||||
@@ -158,42 +154,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return Results.LocalRedirect("/login");
|
return Results.LocalRedirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RenderLoginPage(
|
|
||||||
HttpContext httpContext,
|
|
||||||
IAntiforgery antiforgery,
|
|
||||||
string returnUrl,
|
|
||||||
string? failureMessage)
|
|
||||||
{
|
|
||||||
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
|
||||||
string requestToken = tokens.RequestToken ?? string.Empty;
|
|
||||||
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
|
||||||
? string.Empty
|
|
||||||
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
|
||||||
|
|
||||||
string body = $"""
|
|
||||||
<section class="dashboard-login">
|
|
||||||
{alert}
|
|
||||||
<form method="post" action="/login" class="card login-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
|
||||||
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="username" class="form-label">Username</label>
|
|
||||||
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Password</label>
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
""";
|
|
||||||
|
|
||||||
return RenderPage("Dashboard Sign In", heading: null, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string RenderPage(string title, string body)
|
private static string RenderPage(string title, string body)
|
||||||
=> RenderPage(title, heading: title, body);
|
=> RenderPage(title, heading: title, body);
|
||||||
|
|
||||||
@@ -215,7 +175,8 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/theme.css" />
|
||||||
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/layout.css" />
|
||||||
<link rel="stylesheet" href="/css/site.css" />
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-body">
|
<body class="dashboard-body">
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared-Auth <see cref="IGroupRoleMapper{TRole}"/> seam over the dashboard's
|
||||||
|
/// LDAP-group → role mapping. Roles are plain strings
|
||||||
|
/// (<see cref="DashboardRoles.Admin"/> / <see cref="DashboardRoles.Viewer"/>),
|
||||||
|
/// so <c>TRole</c> is <see cref="string"/>. The mapping rules (full-DN first,
|
||||||
|
/// leading-RDN fallback, case-insensitive) live in
|
||||||
|
/// <see cref="DashboardGroupRoleMapping"/>, shared with
|
||||||
|
/// <see cref="DashboardAuthenticator"/> so behaviour stays identical.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Gateway options supplying the dashboard GroupToRole map.</param>
|
||||||
|
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
|
||||||
|
: IGroupRoleMapper<string>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<GroupRoleMapping<string>> MapAsync(
|
||||||
|
IReadOnlyList<string> groups,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> roles = DashboardGroupRoleMapping.MapGroupsToRoles(
|
||||||
|
groups,
|
||||||
|
options.Value.Dashboard.GroupToRole);
|
||||||
|
|
||||||
|
return Task.FromResult(new GroupRoleMapping<string>(roles, Scope: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for mapping a user's LDAP groups to dashboard roles.
|
||||||
|
/// Both <see cref="DashboardAuthenticator"/> (the existing login flow) and
|
||||||
|
/// <see cref="DashboardGroupRoleMapper"/> (the shared-Auth
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Roles.IGroupRoleMapper{TRole}"/> seam)
|
||||||
|
/// delegate here so the precedence and case rules stay identical.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DashboardGroupRoleMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
||||||
|
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||||
|
/// an empty list when no group matches (caller rejects the login).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
||||||
|
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||||
|
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||||
|
IEnumerable<string> groups,
|
||||||
|
IReadOnlyDictionary<string, string> groupToRole)
|
||||||
|
{
|
||||||
|
if (groupToRole.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> roles = new(StringComparer.Ordinal);
|
||||||
|
foreach (string group in groups)
|
||||||
|
{
|
||||||
|
string normalizedGroup = group.Trim();
|
||||||
|
|
||||||
|
// Lookup precedence (Server-040): the full literal group string is
|
||||||
|
// tried first; only if that misses do we fall back to the leading
|
||||||
|
// RDN value (e.g. "GwAdmin" extracted from
|
||||||
|
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
||||||
|
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
||||||
|
// "GwAdmin" and "gwadmin" both match.
|
||||||
|
//
|
||||||
|
// Review C1: with the shared ZB.MOM.WW.Auth.Ldap provider, groups
|
||||||
|
// arrive here already stripped to short RDN names (the library calls
|
||||||
|
// FirstRdnValue before returning them). So through the live login path
|
||||||
|
// the full-string branch only ever sees short names and the RDN
|
||||||
|
// fallback is effectively a no-op — they collapse to the same key.
|
||||||
|
// The fallback is retained because this mapping is also reachable
|
||||||
|
// directly via the IGroupRoleMapper<string> seam (DashboardGroupRoleMapper),
|
||||||
|
// where a caller could still pass a full DN. CONSEQUENCE: configuring a
|
||||||
|
// full-DN GroupToRole *key* (e.g. "ou=GwAdmin,ou=groups,...") is
|
||||||
|
// UNSUPPORTED with the shared library — the incoming group is a short
|
||||||
|
// name, so it will never equal a full-DN key. Keep GroupToRole keys as
|
||||||
|
// short group names.
|
||||||
|
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
||||||
|
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
||||||
|
{
|
||||||
|
roles.Add(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. roles];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||||
|
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||||
|
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||||
|
{
|
||||||
|
int equalsIndex = distinguishedName.IndexOf('=');
|
||||||
|
if (equalsIndex < 0)
|
||||||
|
{
|
||||||
|
return distinguishedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
int valueStart = equalsIndex + 1;
|
||||||
|
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
||||||
|
|
||||||
|
return commaIndex > valueStart
|
||||||
|
? distinguishedName[valueStart..commaIndex]
|
||||||
|
: distinguishedName[valueStart..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ public static class DashboardRoles
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-write access: API-key CRUD, settings, any state-changing UI.
|
/// Read-write access: API-key CRUD, settings, any state-changing UI.
|
||||||
|
/// Canonical role value (Task 1.7); formerly <c>"Admin"</c> — pure value
|
||||||
|
/// rename, the operations this role authorizes are unchanged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Admin = "Admin";
|
public const string Admin = "Administrator";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-only access: all pages render but write affordances are hidden.
|
/// Read-only access: all pages render but write affordances are hidden.
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
@@ -15,11 +19,25 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
/// Registers all dashboard services, authentication, and Razor components.
|
/// Registers all dashboard services, authentication, and Razor components.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">Service collection to register services.</param>
|
/// <param name="services">Service collection to register services.</param>
|
||||||
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
/// <param name="configuration">
|
||||||
|
/// Application configuration, used to bind the shared LDAP provider's options
|
||||||
|
/// from the <c>MxGateway:Ldap</c> section.
|
||||||
|
/// </param>
|
||||||
|
public static IServiceCollection AddGatewayDashboard(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
// Dashboard logins delegate bind/search to the shared ZB.MOM.WW.Auth.Ldap
|
||||||
|
// provider. Its LdapOptions bind straight from MxGateway:Ldap (the gateway's
|
||||||
|
// LdapOptions field names match the shared options: Transport / AllowInsecure /
|
||||||
|
// SearchBase / ServiceAccount* / *Attribute). AddZbLdapAuth also adds a
|
||||||
|
// ValidateOnStart() so an insecure-transport misconfiguration fails fast at boot.
|
||||||
|
services.AddZbLdapAuth(configuration, "MxGateway:Ldap");
|
||||||
|
|
||||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
|
services.AddSingleton<IGroupRoleMapper<string>, DashboardGroupRoleMapper>();
|
||||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||||
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
||||||
@@ -30,6 +48,7 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
|
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
|
||||||
services.AddHostedService<Hubs.AlarmsHubPublisher>();
|
services.AddHostedService<Hubs.AlarmsHubPublisher>();
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddSingleton<IAuditActorAccessor, HttpAuditActorAccessor>();
|
||||||
services.AddAntiforgery();
|
services.AddAntiforgery();
|
||||||
services.AddCascadingAuthenticationState();
|
services.AddCascadingAuthenticationState();
|
||||||
services.AddRazorComponents()
|
services.AddRazorComponents()
|
||||||
@@ -40,29 +59,42 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
|
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
|
||||||
{
|
{
|
||||||
|
// Hardened defaults (HttpOnly, SameSite=Strict, SecurePolicy, SlidingExpiration,
|
||||||
|
// ExpireTimeSpan) via the shared ZbCookieDefaults.Apply. requireHttps is set to
|
||||||
|
// its default (true / Always) here and overridden per-environment by the
|
||||||
|
// PostConfigure below; the 8-hour idle timeout is preserved (not the 30-min default).
|
||||||
|
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
|
||||||
|
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
|
||||||
|
// so they are never overwritten by the shared helper (Apply intentionally skips name).
|
||||||
|
// This is the canonical default; it is overridden per-environment from
|
||||||
|
// DashboardOptions.CookieName by the PostConfigure below.
|
||||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||||
cookieOptions.Cookie.HttpOnly = true;
|
|
||||||
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
|
||||||
// SecurePolicy is bound via PostConfigure below so it can honour
|
|
||||||
// DashboardOptions.RequireHttpsCookie (default Always; dev HTTP
|
|
||||||
// deployments set RequireHttpsCookie=false to use SameAsRequest).
|
|
||||||
cookieOptions.Cookie.Path = "/";
|
cookieOptions.Cookie.Path = "/";
|
||||||
cookieOptions.LoginPath = "/login";
|
cookieOptions.LoginPath = "/login";
|
||||||
cookieOptions.LogoutPath = "/logout";
|
cookieOptions.LogoutPath = "/logout";
|
||||||
cookieOptions.AccessDeniedPath = "/denied";
|
cookieOptions.AccessDeniedPath = "/denied";
|
||||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
||||||
cookieOptions.SlidingExpiration = true;
|
|
||||||
})
|
})
|
||||||
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
||||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
||||||
_ => { });
|
_ => { });
|
||||||
|
|
||||||
|
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
|
||||||
|
// HTTP deployments → SameAsRequest) and the optional per-environment cookie-name
|
||||||
|
// override. Both run after the inline AddCookie config above, so they win.
|
||||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
||||||
{
|
{
|
||||||
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
|
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
|
||||||
? CookieSecurePolicy.Always
|
? CookieSecurePolicy.Always
|
||||||
: CookieSecurePolicy.SameAsRequest;
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
|
||||||
|
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
|
||||||
|
// the canonical default set above, so a misconfiguration cannot unname the cookie.
|
||||||
|
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(cookieName))
|
||||||
|
{
|
||||||
|
cookieOptions.Cookie.Name = cookieName;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddAuthorization(authorization =>
|
services.AddAuthorization(authorization =>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
@@ -242,7 +243,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
KeyId: key.KeyId,
|
KeyId: key.KeyId,
|
||||||
DisplayName: key.DisplayName,
|
DisplayName: key.DisplayName,
|
||||||
Scopes: key.Scopes,
|
Scopes: key.Scopes,
|
||||||
Constraints: key.Constraints,
|
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
|
||||||
CreatedUtc: key.CreatedUtc,
|
CreatedUtc: key.CreatedUtc,
|
||||||
LastUsedUtc: key.LastUsedUtc,
|
LastUsedUtc: key.LastUsedUtc,
|
||||||
RevokedUtc: key.RevokedUtc))
|
RevokedUtc: key.RevokedUtc))
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Readiness probe: verifies the SQLite authentication store is reachable. The gateway
|
||||||
|
/// authenticates every gRPC call against this store, so its reachability gates readiness.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthStoreHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly AuthSqliteConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
|
||||||
|
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using SqliteConnection connection =
|
||||||
|
await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT 1;";
|
||||||
|
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return HealthCheckResult.Healthy("Auth store is reachable.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Auth store is unreachable.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapts the static <see cref="GatewayLogRedactor"/> to the shared <see cref="ILogRedactor"/> seam
|
||||||
|
/// so the telemetry RedactionEnricher masks API-key/credential material on every log event.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayLogRedactorSeam : ILogRedactor
|
||||||
|
{
|
||||||
|
private static readonly string[] IdentityKeys = ["ClientIdentity", "authorization", "Authorization"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Masks API-key/credential material in known identity-bearing log properties.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="properties">The log event property dictionary to redact in place.</param>
|
||||||
|
public void Redact(IDictionary<string, object?> properties)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
foreach (var key in IdentityKeys)
|
||||||
|
{
|
||||||
|
if (properties.TryGetValue(key, out var value) && value is string s)
|
||||||
|
{
|
||||||
|
properties[key] = GatewayLogRedactor.RedactClientIdentity(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Configuration;
|
using Microsoft.Extensions.Logging.Configuration;
|
||||||
|
using ZB.MOM.WW.Health;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
@@ -14,6 +15,8 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||||
|
using ZB.MOM.WW.Telemetry;
|
||||||
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server;
|
namespace ZB.MOM.WW.MxGateway.Server;
|
||||||
|
|
||||||
@@ -60,18 +63,35 @@ public static class GatewayApplication
|
|||||||
|
|
||||||
ConfigureSelfSignedTls(builder);
|
ConfigureSelfSignedTls(builder);
|
||||||
|
|
||||||
builder.Services.AddGatewayConfiguration();
|
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
|
||||||
builder.Services.AddSqliteAuthStore();
|
|
||||||
|
builder.Services.AddGatewayConfiguration(builder.Configuration);
|
||||||
|
builder.Services.AddSqliteAuthStore(builder.Configuration);
|
||||||
builder.Services.AddGatewayGrpcAuthorization();
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
||||||
|
"auth-store",
|
||||||
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Ready });
|
||||||
builder.Services.AddSingleton<GatewayMetrics>();
|
builder.Services.AddSingleton<GatewayMetrics>();
|
||||||
|
builder.AddZbTelemetry(o =>
|
||||||
|
{
|
||||||
|
o.ServiceName = "mxgateway";
|
||||||
|
o.Meters = [GatewayMetrics.MeterName]; // "MxGateway.Server" — name unchanged
|
||||||
|
if (Enum.TryParse<ZbExporter>(builder.Configuration["MxGateway:Telemetry:Exporter"], ignoreCase: true, out var exporter))
|
||||||
|
o.Exporter = exporter;
|
||||||
|
var otlp = builder.Configuration["MxGateway:Telemetry:OtlpEndpoint"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(otlp))
|
||||||
|
o.OtlpEndpoint = otlp;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorSeam>();
|
||||||
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
||||||
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
||||||
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
||||||
builder.Services.AddWorkerProcessLauncher();
|
builder.Services.AddWorkerProcessLauncher();
|
||||||
builder.Services.AddGatewaySessions();
|
builder.Services.AddGatewaySessions();
|
||||||
builder.Services.AddGatewayAlarms();
|
builder.Services.AddGatewayAlarms();
|
||||||
builder.Services.AddGatewayDashboard();
|
builder.Services.AddGatewayDashboard(builder.Configuration);
|
||||||
builder.Services.AddGalaxyRepository();
|
builder.Services.AddGalaxyRepository();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
@@ -169,13 +189,8 @@ public static class GatewayApplication
|
|||||||
{
|
{
|
||||||
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
||||||
|
|
||||||
endpoints.MapGet(
|
endpoints.MapZbHealth();
|
||||||
"/health/live",
|
endpoints.MapZbMetrics();
|
||||||
() => Results.Ok(new GatewayHealthReply(
|
|
||||||
Status: "Healthy",
|
|
||||||
DefaultBackend: GatewayContractInfo.DefaultBackendName,
|
|
||||||
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
|
|
||||||
.WithName("LiveHealth");
|
|
||||||
|
|
||||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||||
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
|||||||
|
|
||||||
public sealed class GatewayMetrics : IDisposable
|
public sealed class GatewayMetrics : IDisposable
|
||||||
{
|
{
|
||||||
public const string MeterName = "MxGateway.Server";
|
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||||
|
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
private readonly Meter _meter;
|
private readonly Meter _meter;
|
||||||
@@ -68,9 +68,9 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
||||||
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
||||||
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
|
|
||||||
_meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions);
|
_meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions);
|
||||||
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
||||||
@@ -144,7 +144,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_workersRunning++;
|
_workersRunning++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds);
|
_workerStartupLatencyHistogram.Record(startupDuration.TotalSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -208,7 +208,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
|
|
||||||
KeyValuePair<string, object?> methodTag = new("method", method);
|
KeyValuePair<string, object?> methodTag = new("method", method);
|
||||||
_commandsSucceededCounter.Add(1, methodTag);
|
_commandsSucceededCounter.Add(1, methodTag);
|
||||||
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag);
|
_commandLatencyHistogram.Record(duration.TotalSeconds, methodTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -228,7 +228,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
KeyValuePair<string, object?> methodTag = new("method", method);
|
KeyValuePair<string, object?> methodTag = new("method", method);
|
||||||
KeyValuePair<string, object?> categoryTag = new("category", category);
|
KeyValuePair<string, object?> categoryTag = new("category", category);
|
||||||
_commandsFailedCounter.Add(1, methodTag, categoryTag);
|
_commandsFailedCounter.Add(1, methodTag, categoryTag);
|
||||||
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag);
|
_commandLatencyHistogram.Record(duration.TotalSeconds, methodTag, categoryTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -255,7 +255,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
public void RecordEventStreamSend(string family, TimeSpan duration)
|
public void RecordEventStreamSend(string family, TimeSpan duration)
|
||||||
{
|
{
|
||||||
_eventStreamSendLatencyHistogram.Record(
|
_eventStreamSendLatencyHistogram.Record(
|
||||||
duration.TotalMilliseconds,
|
duration.TotalSeconds,
|
||||||
new KeyValuePair<string, object?>("family", family));
|
new KeyValuePair<string, object?>("family", family));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort <see cref="IAuditWriter"/> over the MxGateway-owned
|
||||||
|
/// <see cref="SqliteCanonicalAuditStore"/>. It honours the canonical
|
||||||
|
/// <see cref="IAuditWriter"/> contract: a failed audit write is swallowed and logged
|
||||||
|
/// rather than propagated, so it can never abort the user-facing action that produced it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the single sink through which ALL MxGateway audit flows — the library admin
|
||||||
|
/// verbs (via <see cref="CanonicalForwardingApiKeyAuditStore"/>) and the gateway's own
|
||||||
|
/// dashboard / constraint-denial producers, which write canonical events directly. The
|
||||||
|
/// best-effort wrapping here also closes the gap that the library's
|
||||||
|
/// <c>SqliteApiKeyAuditStore.AppendAsync</c> propagated exceptions.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CanonicalAuditWriter(
|
||||||
|
SqliteCanonicalAuditStore store,
|
||||||
|
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await store.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// Best-effort: a failed audit write must never abort the action that produced it.
|
||||||
|
// Swallow everything (including OperationCanceledException) and log for diagnosis.
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Failed to persist audit event {EventId} (action {Action}); audit write is best-effort and was suppressed.",
|
||||||
|
auditEvent.EventId,
|
||||||
|
auditEvent.Action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter that overrides the shared library's <see cref="IApiKeyAuditStore"/> so that
|
||||||
|
/// library-emitted API-key audit events (CLI / admin verbs from
|
||||||
|
/// <c>ApiKeyAdminCommands</c>) are canonicalized onto <see cref="AuditEvent"/> and routed
|
||||||
|
/// through the gateway's <see cref="IAuditWriter"/> into the canonical
|
||||||
|
/// <c>audit_event</c> store.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Overriding the registered <see cref="IApiKeyAuditStore"/> is the ONLY way to
|
||||||
|
/// canonicalize the library-internal <c>ApiKeyAdminCommands</c> events, since that type
|
||||||
|
/// cannot be edited. <see cref="ListRecentAsync"/> reads back from the canonical store
|
||||||
|
/// and maps each <see cref="AuditEvent"/> to an <see cref="ApiKeyAuditEntry"/> so the
|
||||||
|
/// existing dashboard "recent audit" view (and the CLI/store tests) keep working through
|
||||||
|
/// this same seam, unchanged.
|
||||||
|
/// <para>
|
||||||
|
/// The library's own <c>api_key_audit</c> table is left in place but UNUSED — nothing
|
||||||
|
/// writes to it once this adapter overrides the library's <c>SqliteApiKeyAuditStore</c>
|
||||||
|
/// registration.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CanonicalForwardingApiKeyAuditStore(
|
||||||
|
IAuditWriter auditWriter,
|
||||||
|
SqliteCanonicalAuditStore store) : IApiKeyAuditStore
|
||||||
|
{
|
||||||
|
/// <summary>The canonical <see cref="AuditEvent.Category"/> assigned to API-key events.</summary>
|
||||||
|
public const string ApiKeyCategory = "ApiKey";
|
||||||
|
|
||||||
|
/// <summary>Actor used for the library's keyless <c>init-db</c> event.</summary>
|
||||||
|
private const string SystemActor = "system";
|
||||||
|
|
||||||
|
/// <summary>Actor used for any other keyless (CLI-originated) library event.</summary>
|
||||||
|
private const string CliActor = "cli";
|
||||||
|
|
||||||
|
/// <summary>The library event type that denotes a constraint denial.</summary>
|
||||||
|
private const string ConstraintDeniedEventType = "constraint-denied";
|
||||||
|
|
||||||
|
/// <summary>The library's keyless schema-init event type.</summary>
|
||||||
|
private const string InitDbEventType = "init-db";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
|
|
||||||
|
AuditEvent auditEvent = new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = entry.CreatedUtc,
|
||||||
|
// Keyless library events: init-db is system-originated; any other keyless event
|
||||||
|
// is a CLI/admin verb run without an authenticated principal.
|
||||||
|
Actor = entry.KeyId
|
||||||
|
?? (entry.EventType == InitDbEventType ? SystemActor : CliActor),
|
||||||
|
Action = entry.EventType,
|
||||||
|
Outcome = entry.EventType == ConstraintDeniedEventType
|
||||||
|
? AuditOutcome.Denied
|
||||||
|
: AuditOutcome.Success,
|
||||||
|
Category = ApiKeyCategory,
|
||||||
|
Target = entry.KeyId,
|
||||||
|
SourceNode = entry.RemoteAddress,
|
||||||
|
CorrelationId = null,
|
||||||
|
DetailsJson = WrapDetails(entry.Details),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort: IAuditWriter swallows/logs failures, so this never throws.
|
||||||
|
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
ApiKeyAuditEntry[] entries = new ApiKeyAuditEntry[events.Count];
|
||||||
|
for (int index = 0; index < events.Count; index++)
|
||||||
|
{
|
||||||
|
AuditEvent auditEvent = events[index];
|
||||||
|
entries[index] = new ApiKeyAuditEntry(
|
||||||
|
KeyId: auditEvent.Actor switch
|
||||||
|
{
|
||||||
|
// Keyless library events were mapped to the system/cli sentinel actors on the
|
||||||
|
// way in; map them back to a null KeyId so the dashboard view is faithful.
|
||||||
|
SystemActor or CliActor => null,
|
||||||
|
string actor => actor,
|
||||||
|
},
|
||||||
|
EventType: auditEvent.Action,
|
||||||
|
RemoteAddress: auditEvent.SourceNode,
|
||||||
|
CreatedUtc: auditEvent.OccurredAtUtc,
|
||||||
|
Details: UnwrapDetails(auditEvent.DetailsJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a free-form library detail string into the canonical
|
||||||
|
/// <c>{"detail": "<escaped>"}</c> JSON envelope, or null when there is no detail.
|
||||||
|
/// </summary>
|
||||||
|
private static string? WrapDetails(string? details)
|
||||||
|
{
|
||||||
|
if (details is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = details });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unwraps the canonical detail envelope back to the original free-form string. Falls
|
||||||
|
/// back to the raw JSON when it is not a recognised <c>{"detail": ...}</c> envelope, so
|
||||||
|
/// directly-emitted canonical events (whose DetailsJson is richer) still surface text.
|
||||||
|
/// </summary>
|
||||||
|
private static string? UnwrapDetails(string? detailsJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(detailsJson))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(detailsJson);
|
||||||
|
if (document.RootElement.ValueKind == JsonValueKind.Object
|
||||||
|
&& document.RootElement.TryGetProperty("detail", out JsonElement detail)
|
||||||
|
&& detail.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return detail.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Not JSON we recognise; surface the raw payload below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailsJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP-context-backed implementation of <see cref="IAuditActorAccessor"/> that reads the
|
||||||
|
/// dashboard operator's identity from the current <see cref="IHttpContextAccessor"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Claim resolution order:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><see cref="ZbClaimTypes.Username"/> ("zb:username") — the canonical LDAP login name.</item>
|
||||||
|
/// <item><see cref="ClaimsPrincipal.Identity"/>.<see cref="System.Security.Principal.IIdentity.Name"/> — framework fallback (= <see cref="ZbClaimTypes.Name"/> = <see cref="ClaimTypes.Name"/> = display name).</item>
|
||||||
|
/// <item><see cref="ZbClaimTypes.Name"/> — explicit fallback matching the claim emitted by <c>DashboardAuthenticator.CreatePrincipal</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Returns <see langword="null"/> when there is no HTTP context or the user is not authenticated.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HttpAuditActorAccessor(IHttpContextAccessor httpContextAccessor) : IAuditActorAccessor
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? CurrentActor
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
|
||||||
|
if (user?.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the canonical login-username claim (set by DashboardAuthenticator).
|
||||||
|
string? username = user.FindFirstValue(ZbClaimTypes.Username);
|
||||||
|
if (!string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framework fallback: Identity.Name is driven by the ClaimsIdentity nameClaimType,
|
||||||
|
// which DashboardAuthenticator sets to ZbClaimTypes.Name (= ClaimTypes.Name = display name).
|
||||||
|
string? identityName = user.Identity?.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(identityName))
|
||||||
|
{
|
||||||
|
return identityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final explicit fallback — ZbClaimTypes.Name claim value directly.
|
||||||
|
return user.FindFirstValue(ZbClaimTypes.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current actor name for use in audit events.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Implementations resolve the actor from the ambient request context. For the dashboard
|
||||||
|
/// this is the authenticated LDAP operator; for non-HTTP contexts (gRPC, CLI) the caller
|
||||||
|
/// provides the actor directly and this seam is not used.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAuditActorAccessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current actor's username, or <see langword="null"/> when there is no
|
||||||
|
/// authenticated principal in scope (e.g. an anonymous or unauthenticated request).
|
||||||
|
/// </summary>
|
||||||
|
string? CurrentActor { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MxGateway-owned, append-only SQLite store for canonical
|
||||||
|
/// <see cref="AuditEvent"/>s. It writes to a NEW <c>audit_event</c> table in the
|
||||||
|
/// SAME database file as the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> stores: both share
|
||||||
|
/// the library's <see cref="AuthSqliteConnectionFactory"/> (so they target the same
|
||||||
|
/// <c>ApiKeyOptions.SqlitePath</c> with the same WAL/busy-timeout connection config).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This store is the canonical sink for ALL MxGateway audit. The library's own
|
||||||
|
/// <c>api_key_audit</c> table is left in place but UNUSED after adoption — the library's
|
||||||
|
/// <c>IApiKeyAuditStore</c> registration is overridden by
|
||||||
|
/// <see cref="CanonicalForwardingApiKeyAuditStore"/>, which forwards onto this store via
|
||||||
|
/// <see cref="CanonicalAuditWriter"/>. The library's <c>schema_version</c> /
|
||||||
|
/// <c>api_key_audit</c> tables are not touched here; the <c>audit_event</c> table is
|
||||||
|
/// created idempotently (<c>CREATE TABLE IF NOT EXISTS</c>) on each write so it
|
||||||
|
/// self-bootstraps regardless of migration ordering.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
private const string CreateTableSql =
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_event (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
occurred_at_utc TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
category TEXT NULL,
|
||||||
|
target TEXT NULL,
|
||||||
|
source_node TEXT NULL,
|
||||||
|
correlation_id TEXT NULL,
|
||||||
|
details_json TEXT NULL
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
|
||||||
|
/// <param name="auditEvent">The canonical event to persist.</param>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
||||||
|
|
||||||
|
await using SqliteConnection connection =
|
||||||
|
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_event
|
||||||
|
(event_id, occurred_at_utc, actor, action, outcome,
|
||||||
|
category, target, source_node, correlation_id, details_json)
|
||||||
|
VALUES
|
||||||
|
($event_id, $occurred_at_utc, $actor, $action, $outcome,
|
||||||
|
$category, $target, $source_node, $correlation_id, $details_json);
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$event_id", auditEvent.EventId.ToString());
|
||||||
|
command.Parameters.AddWithValue("$occurred_at_utc", auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||||
|
command.Parameters.AddWithValue("$actor", auditEvent.Actor);
|
||||||
|
command.Parameters.AddWithValue("$action", auditEvent.Action);
|
||||||
|
command.Parameters.AddWithValue("$outcome", auditEvent.Outcome.ToString());
|
||||||
|
command.Parameters.AddWithValue("$category", (object?)auditEvent.Category ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$target", (object?)auditEvent.Target ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$source_node", (object?)auditEvent.SourceNode ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$correlation_id", (object?)auditEvent.CorrelationId?.ToString() ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$details_json", (object?)auditEvent.DetailsJson ?? DBNull.Value);
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
|
||||||
|
/// <param name="limit">Maximum number of events to return.</param>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (limit <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await using SqliteConnection connection =
|
||||||
|
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT event_id, occurred_at_utc, actor, action, outcome,
|
||||||
|
category, target, source_node, correlation_id, details_json
|
||||||
|
FROM audit_event
|
||||||
|
ORDER BY rowid DESC
|
||||||
|
LIMIT $limit;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$limit", limit);
|
||||||
|
|
||||||
|
List<AuditEvent> events = [];
|
||||||
|
|
||||||
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
events.Add(new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.Parse(reader.GetString(0)),
|
||||||
|
OccurredAtUtc = ParseUtc(reader.GetString(1)),
|
||||||
|
Actor = reader.GetString(2),
|
||||||
|
Action = reader.GetString(3),
|
||||||
|
Outcome = Enum.Parse<AuditOutcome>(reader.GetString(4)),
|
||||||
|
Category = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
Target = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
|
SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
|
||||||
|
DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText = CreateTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ParseUtc(string value) =>
|
||||||
|
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes API key administration commands from the CLI.
|
/// Executes API key administration commands from the CLI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ApiKeyAdminCliRunner(
|
/// <remarks>
|
||||||
IAuthStoreMigrator migrator,
|
/// The create/revoke/rotate/list/init-db verbs (secret generation, peppered hashing, token
|
||||||
IApiKeyAdminStore adminStore,
|
/// assembly and per-action audit) are delegated to the shared
|
||||||
IApiKeyAuditStore auditStore,
|
/// <see cref="ApiKeyAdminCommands"/>. This runner adapts the gateway's strongly-typed command and
|
||||||
IApiKeySecretHasher hasher)
|
/// output DTOs (which carry <see cref="ApiKeyConstraints"/>) onto the library's JSON-based contract.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -44,8 +48,7 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
|
|
||||||
private async Task<ApiKeyAdminOutput> InitDbAsync(CancellationToken cancellationToken)
|
private async Task<ApiKeyAdminOutput> InitDbAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
|
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
|
||||||
}
|
}
|
||||||
@@ -54,33 +57,26 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
// The shared command set requires the schema to exist; init-db is idempotent.
|
||||||
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
string keyId = Required(command.KeyId);
|
string keyId = Required(command.KeyId);
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
CreateKeyResult created = await commands.CreateKeyAsync(
|
||||||
string apiKey = FormatApiKey(keyId, secret);
|
keyId,
|
||||||
|
Required(command.DisplayName),
|
||||||
await adminStore.CreateAsync(
|
command.Scopes,
|
||||||
new ApiKeyCreateRequest(
|
ApiKeyConstraintSerializer.Serialize(command.Constraints),
|
||||||
KeyId: keyId,
|
remoteAddress: null,
|
||||||
KeyPrefix: $"mxgw_{keyId}",
|
|
||||||
SecretHash: hasher.HashSecret(secret),
|
|
||||||
DisplayName: Required(command.DisplayName),
|
|
||||||
Scopes: command.Scopes,
|
|
||||||
Constraints: command.Constraints,
|
|
||||||
CreatedUtc: DateTimeOffset.UtcNow),
|
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("create-key", "created", apiKey, []);
|
return new ApiKeyAdminOutput("create-key", "created", created.Token, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ApiKeyAdminOutput> ListKeysAsync(CancellationToken cancellationToken)
|
private async Task<ApiKeyAdminOutput> ListKeysAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
IReadOnlyList<ApiKeyRecord> keys = await adminStore.ListAsync(cancellationToken).ConfigureAwait(false);
|
IReadOnlyList<ApiKeyListItem> keys = await commands.ListKeysAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await AppendAuditAsync(null, "list-keys", null, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput(
|
return new ApiKeyAdminOutput(
|
||||||
"list-keys",
|
"list-keys",
|
||||||
@@ -93,35 +89,28 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
string keyId = Required(command.KeyId);
|
string keyId = Required(command.KeyId);
|
||||||
bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken)
|
KeyActionResult result = await commands.RevokeKeyAsync(keyId, remoteAddress: null, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken)
|
return new ApiKeyAdminOutput("revoke-key", result.Succeeded ? "revoked" : "not-found-or-already-revoked", null, []);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", null, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ApiKeyAdminOutput> RotateKeyAsync(
|
private async Task<ApiKeyAdminOutput> RotateKeyAsync(
|
||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
string keyId = Required(command.KeyId);
|
string keyId = Required(command.KeyId);
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
CreateKeyResult rotated = await commands.RotateKeyAsync(keyId, remoteAddress: null, cancellationToken)
|
||||||
string apiKey = FormatApiKey(keyId, secret);
|
|
||||||
|
|
||||||
bool rotated = await adminStore.RotateAsync(keyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken)
|
bool succeeded = rotated.Token is not null;
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []);
|
return new ApiKeyAdminOutput("rotate-key", succeeded ? "rotated" : "not-found", rotated.Token, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteOutputAsync(
|
private static async Task WriteOutputAsync(
|
||||||
@@ -150,40 +139,19 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AppendAuditAsync(
|
private static ApiKeyAdminListedKey ToListedKey(ApiKeyListItem key)
|
||||||
string? keyId,
|
|
||||||
string eventType,
|
|
||||||
string? details,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await auditStore.AppendAsync(
|
|
||||||
new ApiKeyAuditEntry(
|
|
||||||
KeyId: keyId,
|
|
||||||
EventType: eventType,
|
|
||||||
RemoteAddress: null,
|
|
||||||
Details: details),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ApiKeyAdminListedKey ToListedKey(ApiKeyRecord key)
|
|
||||||
{
|
{
|
||||||
return new ApiKeyAdminListedKey(
|
return new ApiKeyAdminListedKey(
|
||||||
KeyId: key.KeyId,
|
KeyId: key.KeyId,
|
||||||
KeyPrefix: key.KeyPrefix,
|
KeyPrefix: key.KeyPrefix,
|
||||||
DisplayName: key.DisplayName,
|
DisplayName: key.DisplayName,
|
||||||
Scopes: key.Scopes,
|
Scopes: key.Scopes,
|
||||||
Constraints: key.Constraints,
|
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
|
||||||
CreatedUtc: key.CreatedUtc,
|
CreatedUtc: key.CreatedUtc,
|
||||||
LastUsedUtc: key.LastUsedUtc,
|
LastUsedUtc: key.LastUsedUtc,
|
||||||
RevokedUtc: key.RevokedUtc);
|
RevokedUtc: key.RevokedUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatApiKey(string keyId, string secret)
|
|
||||||
{
|
|
||||||
return $"mxgw_{keyId}_{secret}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Required(string? value)
|
private static string Required(string? value)
|
||||||
{
|
{
|
||||||
return value ?? throw new InvalidOperationException("Required command value was not provided.");
|
return value ?? throw new InvalidOperationException("Required command value was not provided.");
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ApiKeyAuditEntry(
|
|
||||||
string? KeyId,
|
|
||||||
string EventType,
|
|
||||||
string? RemoteAddress,
|
|
||||||
string? Details);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ApiKeyAuditRecord(
|
|
||||||
long AuditId,
|
|
||||||
string? KeyId,
|
|
||||||
string EventType,
|
|
||||||
string? RemoteAddress,
|
|
||||||
DateTimeOffset CreatedUtc,
|
|
||||||
string? Details);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ApiKeyCreateRequest(
|
|
||||||
string KeyId,
|
|
||||||
string KeyPrefix,
|
|
||||||
byte[] SecretHash,
|
|
||||||
string DisplayName,
|
|
||||||
IReadOnlySet<string> Scopes,
|
|
||||||
ApiKeyConstraints Constraints,
|
|
||||||
DateTimeOffset CreatedUtc);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class ApiKeyParser : IApiKeyParser
|
|
||||||
{
|
|
||||||
private const string BearerPrefix = "Bearer ";
|
|
||||||
private const string TokenPrefix = "mxgw_";
|
|
||||||
|
|
||||||
/// <summary>Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret.</summary>
|
|
||||||
/// <param name="authorizationHeader">Authorization header value to parse.</param>
|
|
||||||
/// <param name="apiKey">Parsed API key with ID and secret, or null if parsing failed.</param>
|
|
||||||
/// <returns>True if the header was successfully parsed; otherwise, false.</returns>
|
|
||||||
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
|
|
||||||
{
|
|
||||||
apiKey = null;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(authorizationHeader)
|
|
||||||
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
string token = authorizationHeader[BearerPrefix.Length..].Trim();
|
|
||||||
|
|
||||||
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
string keyPayload = token[TokenPrefix.Length..];
|
|
||||||
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
|
|
||||||
|
|
||||||
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
string keyId = keyPayload[..separatorIndex];
|
|
||||||
string secret = keyPayload[(separatorIndex + 1)..];
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKey = new ParsedApiKey(keyId, secret);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class ApiKeyPepperUnavailableException(string pepperSecretName)
|
|
||||||
: InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured.");
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ApiKeyRecord(
|
|
||||||
string KeyId,
|
|
||||||
string KeyPrefix,
|
|
||||||
byte[] SecretHash,
|
|
||||||
string DisplayName,
|
|
||||||
IReadOnlySet<string> Scopes,
|
|
||||||
ApiKeyConstraints Constraints,
|
|
||||||
DateTimeOffset CreatedUtc,
|
|
||||||
DateTimeOffset? LastUsedUtc,
|
|
||||||
DateTimeOffset? RevokedUtc);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>Reads API key records from SQLite query results.</summary>
|
|
||||||
public static class ApiKeyRecordReader
|
|
||||||
{
|
|
||||||
/// <summary>Deserializes a row from the API key table into an ApiKeyRecord.</summary>
|
|
||||||
/// <param name="reader">The data reader positioned at the API key row.</param>
|
|
||||||
/// <returns>The deserialized API key record.</returns>
|
|
||||||
public static ApiKeyRecord Read(SqliteDataReader reader)
|
|
||||||
{
|
|
||||||
return new ApiKeyRecord(
|
|
||||||
KeyId: reader.GetString(0),
|
|
||||||
KeyPrefix: reader.GetString(1),
|
|
||||||
SecretHash: (byte[])reader["secret_hash"],
|
|
||||||
DisplayName: reader.GetString(3),
|
|
||||||
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
|
|
||||||
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
|
|
||||||
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
|
|
||||||
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
|
|
||||||
{
|
|
||||||
return reader.IsDBNull(ordinal)
|
|
||||||
? null
|
|
||||||
: DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public static class ApiKeyScopeSerializer
|
|
||||||
{
|
|
||||||
/// <summary>Serializes scopes to JSON string.</summary>
|
|
||||||
/// <param name="scopes">The scopes to serialize.</param>
|
|
||||||
/// <returns>JSON string representation.</returns>
|
|
||||||
public static string Serialize(IReadOnlySet<string> scopes)
|
|
||||||
{
|
|
||||||
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Deserializes scopes from JSON string.</summary>
|
|
||||||
/// <param name="value">The JSON string to deserialize.</param>
|
|
||||||
/// <returns>Deserialized scopes set.</returns>
|
|
||||||
public static IReadOnlySet<string> Deserialize(string value)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
|
||||||
{
|
|
||||||
return new HashSet<string>(StringComparer.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
|
|
||||||
|
|
||||||
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>Generates cryptographically secure API key secrets.</summary>
|
|
||||||
public static class ApiKeySecretGenerator
|
|
||||||
{
|
|
||||||
/// <summary>Generates a new random API key secret string.</summary>
|
|
||||||
public static string Generate()
|
|
||||||
{
|
|
||||||
Span<byte> bytes = stackalloc byte[32];
|
|
||||||
RandomNumberGenerator.Fill(bytes);
|
|
||||||
|
|
||||||
return Convert.ToBase64String(bytes)
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class ApiKeySecretHasher(
|
|
||||||
IConfiguration configuration,
|
|
||||||
IOptions<GatewayOptions> options) : IApiKeySecretHasher
|
|
||||||
{
|
|
||||||
/// <summary>Hashes an API key secret with pepper using HMAC-SHA256.</summary>
|
|
||||||
/// <param name="secret">The secret to hash.</param>
|
|
||||||
/// <returns>The hashed secret.</returns>
|
|
||||||
public byte[] HashSecret(string secret)
|
|
||||||
{
|
|
||||||
string pepper = GetPepper();
|
|
||||||
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
|
|
||||||
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
|
|
||||||
|
|
||||||
using HMACSHA256 hmac = new(pepperBytes);
|
|
||||||
|
|
||||||
return hmac.ComputeHash(secretBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetPepper()
|
|
||||||
{
|
|
||||||
string pepperSecretName = options.Value.Authentication.PepperSecretName;
|
|
||||||
string? pepper = configuration[pepperSecretName];
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(pepper))
|
|
||||||
{
|
|
||||||
throw new ApiKeyPepperUnavailableException(pepperSecretName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pepper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public enum ApiKeyVerificationFailure
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
MissingOrMalformedCredentials,
|
|
||||||
PepperUnavailable,
|
|
||||||
KeyNotFound,
|
|
||||||
KeyRevoked,
|
|
||||||
SecretMismatch
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ApiKeyVerificationResult(
|
|
||||||
bool Succeeded,
|
|
||||||
ApiKeyIdentity? Identity,
|
|
||||||
ApiKeyVerificationFailure Failure)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a successful verification result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="identity">API key identity.</param>
|
|
||||||
/// <returns>Success result.</returns>
|
|
||||||
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
|
|
||||||
{
|
|
||||||
return new ApiKeyVerificationResult(
|
|
||||||
Succeeded: true,
|
|
||||||
Identity: identity,
|
|
||||||
Failure: ApiKeyVerificationFailure.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a failed verification result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="failure">Verification failure reason.</param>
|
|
||||||
/// <returns>Failure result.</returns>
|
|
||||||
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
|
|
||||||
{
|
|
||||||
return new ApiKeyVerificationResult(
|
|
||||||
Succeeded: false,
|
|
||||||
Identity: null,
|
|
||||||
Failure: failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class ApiKeyVerifier(
|
|
||||||
IApiKeyParser parser,
|
|
||||||
IApiKeySecretHasher hasher,
|
|
||||||
IApiKeyStore keyStore) : IApiKeyVerifier
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies an API key from an authorization header asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="authorizationHeader">Authorization header value.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>Verification result.</returns>
|
|
||||||
public async Task<ApiKeyVerificationResult> VerifyAsync(
|
|
||||||
string? authorizationHeader,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? parsedKey)
|
|
||||||
|| parsedKey is null)
|
|
||||||
{
|
|
||||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.MissingOrMalformedCredentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiKeyRecord? storedKey = await keyStore.FindByKeyIdAsync(parsedKey.KeyId, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (storedKey is null)
|
|
||||||
{
|
|
||||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyNotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storedKey.RevokedUtc is not null)
|
|
||||||
{
|
|
||||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyRevoked);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] presentedHash;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
presentedHash = hasher.HashSecret(parsedKey.Secret);
|
|
||||||
}
|
|
||||||
catch (ApiKeyPepperUnavailableException)
|
|
||||||
{
|
|
||||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.PepperUnavailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
|
|
||||||
{
|
|
||||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
|
||||||
KeyId: storedKey.KeyId,
|
|
||||||
KeyPrefix: storedKey.KeyPrefix,
|
|
||||||
DisplayName: storedKey.DisplayName,
|
|
||||||
Scopes: storedKey.Scopes,
|
|
||||||
Constraints: storedKey.Constraints));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory for creating SQLite connections to the authentication store.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Busy timeout applied to every auth-store connection. SQLite retries a busy
|
|
||||||
/// database for this long before surfacing <c>SQLITE_BUSY</c>, so the concurrent
|
|
||||||
/// <c>MarkKeyUsedAsync</c> / audit-append writers degrade gracefully under load
|
|
||||||
/// instead of failing the request path.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates an unopened SQLite connection to the auth database. Prefer
|
|
||||||
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
|
|
||||||
/// busy timeout.
|
|
||||||
/// </summary>
|
|
||||||
public SqliteConnection CreateConnection()
|
|
||||||
{
|
|
||||||
string sqlitePath = options.Value.Authentication.SqlitePath;
|
|
||||||
string? directory = Path.GetDirectoryName(sqlitePath);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
SqliteConnectionStringBuilder builder = new()
|
|
||||||
{
|
|
||||||
DataSource = sqlitePath,
|
|
||||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
|
||||||
Pooling = true,
|
|
||||||
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new SqliteConnection(builder.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a SQLite connection, opens it, and configures WAL journaling and a
|
|
||||||
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
|
|
||||||
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
||||||
/// <returns>An opened and configured SQLite connection.</returns>
|
|
||||||
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
SqliteConnection connection = CreateConnection();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await connection.DisposeAsync().ConfigureAwait(false);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ConfigureConnectionAsync(
|
|
||||||
SqliteConnection connection,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// WAL is a persistent, database-level setting; re-applying it per connection
|
|
||||||
// is cheap and a no-op once set. busy_timeout is per-connection state.
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText =
|
|
||||||
$"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
|
|
||||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message);
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hosted service that runs authentication store migrations on startup.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AuthStoreMigrationHostedService(
|
|
||||||
IOptions<GatewayOptions> options,
|
|
||||||
IAuthStoreMigrator migrator) : IHostedService
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
AuthenticationOptions authentication = options.Value.Authentication;
|
|
||||||
|
|
||||||
if (authentication.Mode == AuthenticationMode.ApiKey && authentication.RunMigrationsOnStartup)
|
|
||||||
{
|
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+92
-10
@@ -1,27 +1,109 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extension methods for configuring the SQLite authentication store.
|
/// Extension methods for configuring the SQLite authentication store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The peppered-HMAC API-key pipeline (token format, hashing, constant-time compare, SQLite
|
||||||
|
/// schema, stores, verifier and migration) is provided by the shared
|
||||||
|
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> library, of which this gateway is the donor. This wiring binds
|
||||||
|
/// the library's <see cref="ApiKeyOptions"/> from the gateway's <c>MxGateway:Authentication</c>
|
||||||
|
/// section and layers the gateway-specific constraint enforcement, gRPC interceptor, CLI and
|
||||||
|
/// dashboard on top.
|
||||||
|
/// </remarks>
|
||||||
public static class AuthStoreServiceCollectionExtensions
|
public static class AuthStoreServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>The configuration section the gateway binds API-key options from.</summary>
|
||||||
|
public const string AuthenticationSectionPath = "MxGateway:Authentication";
|
||||||
|
|
||||||
|
/// <summary>The gateway API-key token prefix (token format <c>mxgw_<id>_<secret></c>).</summary>
|
||||||
|
public const string TokenPrefix = "mxgw";
|
||||||
|
|
||||||
|
/// <summary>The configuration key the API-key pepper is resolved from.</summary>
|
||||||
|
public const string PepperSecretName = "MxGateway:ApiKeyPepper";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds the SQLite authentication store and related services to the dependency container.
|
/// Adds the SQLite authentication store and related services to the dependency container.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">Service collection to configure.</param>
|
/// <param name="services">Service collection to configure.</param>
|
||||||
|
/// <param name="configuration">Application configuration carrying the API-key options.</param>
|
||||||
/// <returns>The service collection for chaining.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
|
public static IServiceCollection AddSqliteAuthStore(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
|
|
||||||
|
// Pin the gateway's API-key contract (token prefix "mxgw"; pepper resolved from
|
||||||
|
// MxGateway:ApiKeyPepper) by layering fallback defaults UNDER the supplied configuration:
|
||||||
|
// an in-memory source provides TokenPrefix/PepperSecretName only when the bound
|
||||||
|
// MxGateway:Authentication section omits them (the section has no TokenPrefix, and the pepper
|
||||||
|
// is intentionally not in appsettings — it is supplied at runtime). Explicit config wins
|
||||||
|
// because it is added last. ApiKeyOptions is an init-only record, so the values must be
|
||||||
|
// present at bind time rather than mutated post-configure.
|
||||||
|
IConfiguration effectiveConfig = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
[$"{AuthenticationSectionPath}:TokenPrefix"] = TokenPrefix,
|
||||||
|
[$"{AuthenticationSectionPath}:PepperSecretName"] = PepperSecretName,
|
||||||
|
})
|
||||||
|
.AddConfiguration(configuration)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Register the shared API-key provider: binds ApiKeyOptions from MxGateway:Authentication,
|
||||||
|
// wires up the SQLite stores, the configuration-backed pepper provider, the verifier, the
|
||||||
|
// migrator and the migration hosted service.
|
||||||
|
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
|
||||||
|
|
||||||
|
// Canonical audit (Task 2.3 — DEEP adopt ZB.MOM.WW.Audit). All MxGateway audit flows as a
|
||||||
|
// canonical AuditEvent through the library IAuditWriter, persisted in a NEW gateway-owned
|
||||||
|
// audit_event table that lives in the SAME SQLite DB file as the api-key stores (it reuses
|
||||||
|
// the library's AuthSqliteConnectionFactory, registered by AddZbApiKeyAuth above).
|
||||||
|
services.AddSingleton(sp =>
|
||||||
|
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||||
|
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
|
||||||
|
// DI-only auth/CLI/dashboard unit tests build a bare ServiceCollection without AddLogging().
|
||||||
|
// Fall back to NullLogger there so the audit writer (and the IApiKeyAuditStore override that
|
||||||
|
// depends on it) still resolve. The write path is best-effort regardless.
|
||||||
|
services.AddSingleton<IAuditWriter>(sp =>
|
||||||
|
new CanonicalAuditWriter(
|
||||||
|
sp.GetRequiredService<SqliteCanonicalAuditStore>(),
|
||||||
|
sp.GetService<ILogger<CanonicalAuditWriter>>()
|
||||||
|
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<CanonicalAuditWriter>.Instance));
|
||||||
|
|
||||||
|
// OVERRIDE the library's IApiKeyAuditStore (AddZbApiKeyAuth registered the library's
|
||||||
|
// SqliteApiKeyAuditStore via TryAddSingleton) with an adapter that canonicalizes every
|
||||||
|
// library-emitted ApiKeyAuditEntry onto AuditEvent and forwards it through IAuditWriter.
|
||||||
|
// This is the only way to canonicalize the library-internal ApiKeyAdminCommands verbs
|
||||||
|
// (create/revoke/rotate/init-db, etc.), which we cannot edit. The adapter is registered
|
||||||
|
// after AddZbApiKeyAuth so it (last registration) is what ApiKeyAdminCommands resolves and
|
||||||
|
// what the dashboard "recent audit" view reads via IApiKeyAuditStore.ListRecentAsync.
|
||||||
|
// The library's api_key_audit table is left in place but is now UNUSED — nothing writes to
|
||||||
|
// it once this adapter replaces the library's audit store.
|
||||||
|
services.AddSingleton<IApiKeyAuditStore, CanonicalForwardingApiKeyAuditStore>();
|
||||||
|
|
||||||
|
// The shared admin command set (create/revoke/rotate/list/init-db with audit) is not
|
||||||
|
// auto-registered by AddZbApiKeyAuth; the gateway CLI and dashboard drive it, so register
|
||||||
|
// it here over the already-wired stores, pepper provider and migrator.
|
||||||
|
services.AddSingleton(sp => new ApiKeyAdminCommands(
|
||||||
|
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
|
||||||
|
sp.GetRequiredService<IApiKeyAdminStore>(),
|
||||||
|
sp.GetRequiredService<IApiKeyAuditStore>(),
|
||||||
|
sp.GetRequiredService<IApiKeyPepperProvider>(),
|
||||||
|
sp.GetRequiredService<SqliteAuthStoreMigrator>()));
|
||||||
|
|
||||||
services.AddSingleton<ApiKeyAdminCliRunner>();
|
services.AddSingleton<ApiKeyAdminCliRunner>();
|
||||||
services.AddSingleton<AuthSqliteConnectionFactory>();
|
|
||||||
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
|
|
||||||
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
|
|
||||||
services.AddSingleton<IApiKeyAdminStore, SqliteApiKeyAdminStore>();
|
|
||||||
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
|
|
||||||
services.AddHostedService<AuthStoreMigrationHostedService>();
|
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the shared <see cref="ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity"/> (which
|
||||||
|
/// carries the key's scopes plus the opaque constraints JSON blob) onto the gateway's
|
||||||
|
/// <see cref="ApiKeyIdentity"/> (which exposes the deserialized
|
||||||
|
/// <see cref="ApiKeyConstraints"/> the downstream authorization code enforces).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The shared verifier does not interpret the constraints column; it returns the stored
|
||||||
|
/// JSON verbatim in <see cref="ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity.Constraints"/>.
|
||||||
|
/// This mapper re-hydrates it via <see cref="ApiKeyConstraintSerializer"/> so the gateway's
|
||||||
|
/// constraint enforcement (<c>ConstraintEnforcer</c>) and request-identity accessor continue
|
||||||
|
/// to operate on the strongly-typed model unchanged.
|
||||||
|
/// </remarks>
|
||||||
|
public static class GatewayApiKeyIdentityMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a shared API-key identity into the gateway identity, deserializing the opaque
|
||||||
|
/// constraints JSON into <see cref="ApiKeyConstraints"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="identity">The shared identity returned by the library verifier.</param>
|
||||||
|
/// <returns>The gateway identity carrying the effective constraints.</returns>
|
||||||
|
public static ApiKeyIdentity ToGatewayIdentity(LibApiKeyIdentity identity)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(identity);
|
||||||
|
|
||||||
|
// The library stores the opaque constraints blob in Constraints as the ConstraintsJson
|
||||||
|
// string (or null when the key has no constraints).
|
||||||
|
string? constraintsJson = identity.Constraints as string;
|
||||||
|
|
||||||
|
return new ApiKeyIdentity(
|
||||||
|
KeyId: identity.KeyId,
|
||||||
|
// The gateway token prefix is fixed ("mxgw"); the key id is its own field. KeyPrefix
|
||||||
|
// is retained only for surface compatibility with the gateway identity record.
|
||||||
|
KeyPrefix: "mxgw",
|
||||||
|
DisplayName: identity.DisplayName,
|
||||||
|
Scopes: identity.Scopes,
|
||||||
|
Constraints: ApiKeyConstraintSerializer.Deserialize(constraintsJson));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public interface IApiKeyAdminStore
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new API key asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">API key creation request.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>Completed task.</returns>
|
|
||||||
Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lists all API keys asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>List of API key records.</returns>
|
|
||||||
Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Revokes an API key asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="keyId">Key identifier.</param>
|
|
||||||
/// <param name="revokedUtc">Revocation timestamp.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>True if revoked; otherwise false.</returns>
|
|
||||||
Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rotates an API key secret asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="keyId">Key identifier.</param>
|
|
||||||
/// <param name="secretHash">New secret hash.</param>
|
|
||||||
/// <param name="rotatedUtc">Rotation timestamp.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>True if rotated; otherwise false.</returns>
|
|
||||||
Task<bool> RotateAsync(
|
|
||||||
string keyId,
|
|
||||||
byte[] secretHash,
|
|
||||||
DateTimeOffset rotatedUtc,
|
|
||||||
CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Permanently deletes an API key, but only if it is already revoked. Active keys are
|
|
||||||
/// untouched (returns false) so an admin cannot delete a working credential without
|
|
||||||
/// first revoking it — that preserves the audit trail and forces the revoke event to
|
|
||||||
/// land in the audit log before the row disappears.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="keyId">Key identifier.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>True if a revoked key was deleted; false if the key is missing or active.</returns>
|
|
||||||
Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stores and retrieves audit events for API key operations.
|
|
||||||
/// </summary>
|
|
||||||
public interface IApiKeyAuditStore
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Appends an audit entry to the audit log.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="entry">Audit entry to record.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the append operation.</returns>
|
|
||||||
Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lists the most recent audit entries, up to the specified count.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="count">Maximum number of entries to return.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task returning the list of audit records.</returns>
|
|
||||||
Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public interface IApiKeyParser
|
|
||||||
{
|
|
||||||
/// <summary>Attempts to parse an authorization header and extract the API key.</summary>
|
|
||||||
/// <param name="authorizationHeader">Authorization header value to parse.</param>
|
|
||||||
/// <param name="apiKey">Parsed API key if successful.</param>
|
|
||||||
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public interface IApiKeySecretHasher
|
|
||||||
{
|
|
||||||
/// <summary>Hashes an API key secret and returns the hash bytes.</summary>
|
|
||||||
/// <param name="secret">API key secret to hash.</param>
|
|
||||||
byte[] HashSecret(string secret);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>Persists API keys and audit records for authentication and accounting.</summary>
|
|
||||||
public interface IApiKeyStore
|
|
||||||
{
|
|
||||||
/// <summary>Retrieves an API key by ID regardless of revocation status.</summary>
|
|
||||||
/// <param name="keyId">Identifier of the API key.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Retrieves an active (non-revoked) API key by ID.</summary>
|
|
||||||
/// <param name="keyId">Identifier of the API key.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Records that an API key was used for auditing and tracking.</summary>
|
|
||||||
/// <param name="keyId">Identifier of the API key.</param>
|
|
||||||
/// <param name="usedUtc">Timestamp when the key was used in UTC.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>Verifies API key authorization headers and returns the authenticated identity.</summary>
|
|
||||||
public interface IApiKeyVerifier
|
|
||||||
{
|
|
||||||
/// <summary>Parses and verifies an authorization header, returning success with identity or a failure reason.</summary>
|
|
||||||
/// <param name="authorizationHeader">The authorization header value to verify.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<ApiKeyVerificationResult> VerifyAsync(
|
|
||||||
string? authorizationHeader,
|
|
||||||
CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>Migrates authentication storage between versions.</summary>
|
|
||||||
public interface IAuthStoreMigrator
|
|
||||||
{
|
|
||||||
/// <summary>Performs authentication store migration asynchronously.</summary>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the migration operation.</returns>
|
|
||||||
Task MigrateAsync(CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ParsedApiKey(string KeyId, string Secret);
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SQLite-backed storage for API key administration (create, list, revoke, rotate).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
INSERT INTO api_keys (
|
|
||||||
key_id,
|
|
||||||
key_prefix,
|
|
||||||
secret_hash,
|
|
||||||
display_name,
|
|
||||||
scopes,
|
|
||||||
constraints,
|
|
||||||
created_utc,
|
|
||||||
last_used_utc,
|
|
||||||
revoked_utc)
|
|
||||||
VALUES (
|
|
||||||
$key_id,
|
|
||||||
$key_prefix,
|
|
||||||
$secret_hash,
|
|
||||||
$display_name,
|
|
||||||
$scopes,
|
|
||||||
$constraints,
|
|
||||||
$created_utc,
|
|
||||||
NULL,
|
|
||||||
NULL);
|
|
||||||
""";
|
|
||||||
AddCreateParameters(command, request);
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
|
||||||
FROM api_keys
|
|
||||||
ORDER BY key_id;
|
|
||||||
""";
|
|
||||||
|
|
||||||
List<ApiKeyRecord> records = [];
|
|
||||||
|
|
||||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
records.Add(ApiKeyRecordReader.Read(reader));
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
UPDATE api_keys
|
|
||||||
SET revoked_utc = $revoked_utc
|
|
||||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$key_id", keyId);
|
|
||||||
command.Parameters.AddWithValue("$revoked_utc", revokedUtc.ToString("O"));
|
|
||||||
|
|
||||||
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return rows > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<bool> RotateAsync(
|
|
||||||
string keyId,
|
|
||||||
byte[] secretHash,
|
|
||||||
DateTimeOffset rotatedUtc,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
UPDATE api_keys
|
|
||||||
SET secret_hash = $secret_hash,
|
|
||||||
last_used_utc = NULL,
|
|
||||||
revoked_utc = NULL
|
|
||||||
WHERE key_id = $key_id;
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$key_id", keyId);
|
|
||||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = secretHash;
|
|
||||||
|
|
||||||
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return rows > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
DELETE FROM api_keys
|
|
||||||
WHERE key_id = $key_id AND revoked_utc IS NOT NULL;
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$key_id", keyId);
|
|
||||||
|
|
||||||
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return rows > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request)
|
|
||||||
{
|
|
||||||
command.Parameters.AddWithValue("$key_id", request.KeyId);
|
|
||||||
command.Parameters.AddWithValue("$key_prefix", request.KeyPrefix);
|
|
||||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
|
|
||||||
command.Parameters.AddWithValue("$display_name", request.DisplayName);
|
|
||||||
command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
|
|
||||||
command.Parameters.AddWithValue(
|
|
||||||
"$constraints",
|
|
||||||
(object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value);
|
|
||||||
command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
INSERT INTO api_key_audit (key_id, event_type, remote_address, created_utc, details)
|
|
||||||
VALUES ($key_id, $event_type, $remote_address, $created_utc, $details);
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$key_id", (object?)entry.KeyId ?? DBNull.Value);
|
|
||||||
command.Parameters.AddWithValue("$event_type", entry.EventType);
|
|
||||||
command.Parameters.AddWithValue("$remote_address", (object?)entry.RemoteAddress ?? DBNull.Value);
|
|
||||||
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
|
|
||||||
command.Parameters.AddWithValue("$details", (object?)entry.Details ?? DBNull.Value);
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (count <= 0)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
SELECT audit_id, key_id, event_type, remote_address, created_utc, details
|
|
||||||
FROM api_key_audit
|
|
||||||
ORDER BY audit_id DESC
|
|
||||||
LIMIT $count;
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$count", count);
|
|
||||||
|
|
||||||
List<ApiKeyAuditRecord> records = [];
|
|
||||||
|
|
||||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
records.Add(new ApiKeyAuditRecord(
|
|
||||||
AuditId: reader.GetInt64(0),
|
|
||||||
KeyId: reader.IsDBNull(1) ? null : reader.GetString(1),
|
|
||||||
EventType: reader.GetString(2),
|
|
||||||
RemoteAddress: reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
||||||
CreatedUtc: DateTimeOffset.Parse(
|
|
||||||
reader.GetString(4),
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
Details: reader.IsDBNull(5) ? null : reader.GetString(5)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
/// <summary>SQLite-based store for API key records.</summary>
|
|
||||||
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = """
|
|
||||||
UPDATE api_keys
|
|
||||||
SET last_used_utc = $last_used_utc
|
|
||||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$key_id", keyId);
|
|
||||||
command.Parameters.AddWithValue("$last_used_utc", usedUtc.ToString("O"));
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ApiKeyRecord?> FindByKeyIdAsync(
|
|
||||||
string keyId,
|
|
||||||
bool requireActive,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = requireActive
|
|
||||||
? """
|
|
||||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
|
||||||
FROM api_keys
|
|
||||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
|
||||||
"""
|
|
||||||
: """
|
|
||||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
|
||||||
FROM api_keys
|
|
||||||
WHERE key_id = $key_id;
|
|
||||||
""";
|
|
||||||
command.Parameters.AddWithValue("$key_id", keyId);
|
|
||||||
|
|
||||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ApiKeyRecordReader.Read(reader);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public static class SqliteAuthSchema
|
|
||||||
{
|
|
||||||
public const int CurrentVersion = 2;
|
|
||||||
|
|
||||||
public const string SchemaVersionTable = "schema_version";
|
|
||||||
|
|
||||||
public const string ApiKeysTable = "api_keys";
|
|
||||||
|
|
||||||
public const string ApiKeyAuditTable = "api_key_audit";
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator
|
|
||||||
{
|
|
||||||
/// <summary>Applies database migrations to the authentication store.</summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await using SqliteTransaction transaction =
|
|
||||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
int existingVersion = await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (existingVersion > SqliteAuthSchema.CurrentVersion)
|
|
||||||
{
|
|
||||||
throw new AuthStoreMigrationException(
|
|
||||||
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
|
||||||
await ApplyVersionTwoAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
|
||||||
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> ReadExistingSchemaVersionAsync(
|
|
||||||
SqliteConnection connection,
|
|
||||||
SqliteTransaction transaction,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteCommand tableExistsCommand = connection.CreateCommand();
|
|
||||||
tableExistsCommand.Transaction = transaction;
|
|
||||||
tableExistsCommand.CommandText = """
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM sqlite_master
|
|
||||||
WHERE type = 'table' AND name = $table_name;
|
|
||||||
""";
|
|
||||||
tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable);
|
|
||||||
|
|
||||||
long tableCount = (long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
|
|
||||||
|
|
||||||
if (tableCount == 0)
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
|
||||||
versionCommand.Transaction = transaction;
|
|
||||||
versionCommand.CommandText = """
|
|
||||||
SELECT version
|
|
||||||
FROM schema_version
|
|
||||||
WHERE id = 1;
|
|
||||||
""";
|
|
||||||
|
|
||||||
object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return version is null || version == DBNull.Value
|
|
||||||
? 0
|
|
||||||
: Convert.ToInt32(version, System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ApplyVersionOneAsync(
|
|
||||||
SqliteConnection connection,
|
|
||||||
SqliteTransaction transaction,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await ExecuteNonQueryAsync(
|
|
||||||
connection,
|
|
||||||
transaction,
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
version INTEGER NOT NULL,
|
|
||||||
applied_utc TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
key_id TEXT PRIMARY KEY,
|
|
||||||
key_prefix TEXT NOT NULL,
|
|
||||||
secret_hash BLOB NOT NULL,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
scopes TEXT NOT NULL,
|
|
||||||
constraints TEXT NULL,
|
|
||||||
created_utc TEXT NOT NULL,
|
|
||||||
last_used_utc TEXT NULL,
|
|
||||||
revoked_utc TEXT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_key_audit (
|
|
||||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key_id TEXT NULL,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
remote_address TEXT NULL,
|
|
||||||
created_utc TEXT NOT NULL,
|
|
||||||
details TEXT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc
|
|
||||||
ON api_keys (revoked_utc);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc
|
|
||||||
ON api_key_audit (key_id, created_utc);
|
|
||||||
""",
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ApplyVersionTwoAsync(
|
|
||||||
SqliteConnection connection,
|
|
||||||
SqliteTransaction transaction,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (await ColumnExistsAsync(connection, transaction, SqliteAuthSchema.ApiKeysTable, "constraints", cancellationToken)
|
|
||||||
.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExecuteNonQueryAsync(
|
|
||||||
connection,
|
|
||||||
transaction,
|
|
||||||
"""
|
|
||||||
ALTER TABLE api_keys
|
|
||||||
ADD COLUMN constraints TEXT NULL;
|
|
||||||
""",
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task WriteSchemaVersionAsync(
|
|
||||||
SqliteConnection connection,
|
|
||||||
SqliteTransaction transaction,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
|
||||||
versionCommand.Transaction = transaction;
|
|
||||||
versionCommand.CommandText = """
|
|
||||||
INSERT INTO schema_version (id, version, applied_utc)
|
|
||||||
VALUES (1, $version, $applied_utc)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
version = excluded.version,
|
|
||||||
applied_utc = excluded.applied_utc;
|
|
||||||
""";
|
|
||||||
versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion);
|
|
||||||
versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
|
|
||||||
|
|
||||||
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> ColumnExistsAsync(
|
|
||||||
SqliteConnection connection,
|
|
||||||
SqliteTransaction transaction,
|
|
||||||
string tableName,
|
|
||||||
string columnName,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.Transaction = transaction;
|
|
||||||
command.CommandText = $"PRAGMA table_info({tableName});";
|
|
||||||
|
|
||||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ExecuteNonQueryAsync(
|
|
||||||
SqliteConnection connection,
|
|
||||||
SqliteTransaction transaction,
|
|
||||||
string commandText,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using SqliteCommand command = connection.CreateCommand();
|
|
||||||
command.Transaction = transaction;
|
|
||||||
command.CommandText = commandText;
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
// The gateway carries its own constraint-bearing identity downstream; the shared library also
|
||||||
|
// defines an ApiKeyIdentity (scopes + opaque constraints JSON), so disambiguate to the gateway type.
|
||||||
|
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
public sealed class ConstraintEnforcer(
|
public sealed class ConstraintEnforcer(
|
||||||
IGalaxyHierarchyCache cache,
|
IGalaxyHierarchyCache cache,
|
||||||
IApiKeyAuditStore auditStore) : IConstraintEnforcer
|
IAuditWriter auditWriter) : IConstraintEnforcer
|
||||||
{
|
{
|
||||||
/// <summary>Checks read constraints on a tag address.</summary>
|
/// <summary>Checks read constraints on a tag address.</summary>
|
||||||
/// <param name="identity">The API key identity to check constraints for.</param>
|
/// <param name="identity">The API key identity to check constraints for.</param>
|
||||||
@@ -121,14 +128,33 @@ public sealed class ConstraintEnforcer(
|
|||||||
ConstraintFailure failure,
|
ConstraintFailure failure,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await auditStore.AppendAsync(
|
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter
|
||||||
new ApiKeyAuditEntry(
|
// (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson
|
||||||
KeyId: identity?.KeyId,
|
// envelope carrying constraint/message/commandKind/target.
|
||||||
EventType: "constraint-denied",
|
// TODO(Task 2.3): CorrelationId is left null here. Threading the per-request
|
||||||
RemoteAddress: null,
|
// ClientCorrelationId down to RecordDenialAsync would require an invasive IConstraintEnforcer
|
||||||
Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"),
|
// signature change across the gRPC call path; that is deferred to a follow-up.
|
||||||
cancellationToken)
|
AuditEvent auditEvent = new()
|
||||||
.ConfigureAwait(false);
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
Actor = identity?.KeyId ?? "anonymous",
|
||||||
|
Action = "constraint-denied",
|
||||||
|
Outcome = AuditOutcome.Denied,
|
||||||
|
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
|
||||||
|
Target = $"{commandKind}:{target}",
|
||||||
|
SourceNode = null,
|
||||||
|
CorrelationId = null,
|
||||||
|
DetailsJson = JsonSerializer.Serialize(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["constraint"] = failure.ConstraintName,
|
||||||
|
["message"] = failure.Message,
|
||||||
|
["commandKind"] = commandKind,
|
||||||
|
["target"] = target,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ConstraintFailure? CheckReadTarget(
|
private ConstraintFailure? CheckReadTarget(
|
||||||
|
|||||||
+16
-5
@@ -1,9 +1,14 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Grpc.Core.Interceptors;
|
using Grpc.Core.Interceptors;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
// The handler pushes the gateway's constraint-bearing identity; alias away the shared library's
|
||||||
|
// ApiKeyIdentity so the unqualified name resolves to the gateway type.
|
||||||
|
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
public sealed class GatewayGrpcAuthorizationInterceptor(
|
public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||||
@@ -57,25 +62,31 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
||||||
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
|
|
||||||
.VerifyAsync(authorizationHeader, context.CancellationToken)
|
// The shared verifier owns parse + pepper + lookup + revocation + constant-time compare,
|
||||||
|
// returning a discriminated failure rather than throwing. Every authentication failure maps
|
||||||
|
// to Unauthenticated with an opaque message; the client never learns which stage failed.
|
||||||
|
ApiKeyVerification verification = await apiKeyVerifier
|
||||||
|
.VerifyAsync(authorizationHeader ?? string.Empty, context.CancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (!verificationResult.Succeeded || verificationResult.Identity is null)
|
if (!verification.Succeeded || verification.Identity is null)
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(
|
throw new RpcException(new Status(
|
||||||
StatusCode.Unauthenticated,
|
StatusCode.Unauthenticated,
|
||||||
"Missing or invalid API key."));
|
"Missing or invalid API key."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
|
||||||
|
|
||||||
string requiredScope = scopeResolver.ResolveRequiredScope(request);
|
string requiredScope = scopeResolver.ResolveRequiredScope(request);
|
||||||
if (!verificationResult.Identity.Scopes.Contains(requiredScope))
|
if (!identity.Scopes.Contains(requiredScope))
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(
|
throw new RpcException(new Status(
|
||||||
StatusCode.PermissionDenied,
|
StatusCode.PermissionDenied,
|
||||||
$"API key is missing required scope '{requiredScope}'."));
|
$"API key is missing required scope '{requiredScope}'."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return verificationResult.Identity;
|
return identity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,22 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.2" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.2" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
|
||||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||||
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": { "Microsoft.AspNetCore": "Warning" }
|
||||||
}
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{ "Name": "File", "Args": { "path": "logs/mxgateway-.log", "rollingInterval": "Day" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||||
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": { "Microsoft.AspNetCore": "Warning" }
|
||||||
}
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{ "Name": "File", "Args": { "path": "logs/mxgateway-.log", "rollingInterval": "Day" } }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"MxGateway": {
|
"MxGateway": {
|
||||||
@@ -17,10 +22,10 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"Server": "localhost",
|
"Server": "localhost",
|
||||||
"Port": 3893,
|
"Port": 3893,
|
||||||
"UseTls": false,
|
"Transport": "None",
|
||||||
"AllowInsecureLdap": true,
|
"AllowInsecure": true,
|
||||||
"SearchBase": "dc=lmxopcua,dc=local",
|
"SearchBase": "dc=zb,dc=local",
|
||||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||||
"ServiceAccountPassword": "serviceaccount123",
|
"ServiceAccountPassword": "serviceaccount123",
|
||||||
"UserNameAttribute": "cn",
|
"UserNameAttribute": "cn",
|
||||||
"DisplayNameAttribute": "cn",
|
"DisplayNameAttribute": "cn",
|
||||||
@@ -55,7 +60,7 @@
|
|||||||
"RecentSessionLimit": 200,
|
"RecentSessionLimit": 200,
|
||||||
"ShowTagValues": false,
|
"ShowTagValues": false,
|
||||||
"GroupToRole": {
|
"GroupToRole": {
|
||||||
"GwAdmin": "Admin",
|
"GwAdmin": "Administrator",
|
||||||
"GwReader": "Viewer"
|
"GwReader": "Viewer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,108 +9,13 @@
|
|||||||
body.dashboard-body { min-height: 100vh; }
|
body.dashboard-body { min-height: 100vh; }
|
||||||
|
|
||||||
/* ── App bar ─────────────────────────────────────────────────────────────────
|
/* ── App bar ─────────────────────────────────────────────────────────────────
|
||||||
theme.css styles .app-bar / .brand / .mark / .spacer. Here we centre the row
|
The kit's theme.css styles .app-bar / .brand / .mark / .spacer; these rules
|
||||||
and add the meta cluster. Navigation lives in the side rail below. */
|
centre the row and tweak the brand. Used by the minimal /denied page header —
|
||||||
|
the main dashboard's navigation lives in the kit side-rail shell (ThemeShell). */
|
||||||
.app-bar { align-items: center; gap: 1rem; }
|
.app-bar { align-items: center; gap: 1rem; }
|
||||||
.app-bar .brand { color: var(--ink); }
|
.app-bar .brand { color: var(--ink); }
|
||||||
.app-bar .brand:hover { text-decoration: none; }
|
.app-bar .brand:hover { text-decoration: none; }
|
||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────────────────────────────────────────
|
|
||||||
Left-rail navigation. Pattern lifted from ScadaLink CentralUI: a fixed-width
|
|
||||||
sidebar that hosts the brand at the top, a scrollable nav region with
|
|
||||||
collapsible NavSections in the middle, and a sign-in/out footer at the
|
|
||||||
bottom. The sidebar is wrapped in a Bootstrap .collapse so a hamburger
|
|
||||||
button can show/hide it on <lg viewports. */
|
|
||||||
.sidebar {
|
|
||||||
min-width: 220px;
|
|
||||||
max-width: 220px;
|
|
||||||
height: 100vh;
|
|
||||||
background: var(--card);
|
|
||||||
border-right: 1px solid var(--rule-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pin the sidebar to the viewport on lg+ so it stays visible when the main
|
|
||||||
content scrolls past 100vh. The wrapper is the flex child of MainLayout;
|
|
||||||
align-self prevents the flex row from stretching it. */
|
|
||||||
@media (min-width: 992px) {
|
|
||||||
#sidebar-collapse {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
|
||||||
align-self: flex-start;
|
|
||||||
z-index: 1020;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When collapsed under <lg viewports the Bootstrap collapse container removes
|
|
||||||
the fixed width; restore full width on mobile. */
|
|
||||||
@media (max-width: 991.98px) {
|
|
||||||
.sidebar {
|
|
||||||
min-width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .brand {
|
|
||||||
display: block;
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid var(--rule);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.sidebar .brand:hover { text-decoration: none; }
|
|
||||||
.sidebar .brand .mark { color: var(--accent); }
|
|
||||||
|
|
||||||
.sidebar .nav-link {
|
|
||||||
color: var(--ink-soft);
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.sidebar .nav-link:hover {
|
|
||||||
color: var(--ink);
|
|
||||||
background-color: var(--paper);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.sidebar .nav-link.active {
|
|
||||||
color: var(--accent-deep);
|
|
||||||
background-color: var(--paper);
|
|
||||||
font-weight: 600;
|
|
||||||
/* Left accent so active state isn't carried by colour alone. */
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
padding-left: calc(1rem - 3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapsible section header — a full-width button styled as an uppercase
|
|
||||||
eyebrow with a leading expand/collapse chevron. */
|
|
||||||
.sidebar .nav-section-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
width: 100%;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
padding: 0.75rem 1rem 0.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
.sidebar .nav-section-toggle:hover { color: var(--ink); }
|
|
||||||
.sidebar .nav-section-toggle .chevron {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.8rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Page header ─────────────────────────────────────────────────────────────
|
/* ── Page header ─────────────────────────────────────────────────────────────
|
||||||
h1 in sans, the sub-line in monospace as a quiet meta crumb. */
|
h1 in sans, the sub-line in monospace as a quiet meta crumb. */
|
||||||
.dashboard-page-header {
|
.dashboard-page-header {
|
||||||
@@ -337,14 +242,6 @@ code {
|
|||||||
.alert-success { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
|
.alert-success { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
|
||||||
.alert-danger { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
|
.alert-danger { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
|
||||||
|
|
||||||
/* ── Login ───────────────────────────────────────────────────────────────────*/
|
|
||||||
.dashboard-login { max-width: 24rem; margin: 0 auto; }
|
|
||||||
.login-card {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--rule);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── API key management ──────────────────────────────────────────────────────*/
|
/* ── API key management ──────────────────────────────────────────────────────*/
|
||||||
.api-key-management-grid {
|
.api-key-management-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
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.
@@ -1,19 +0,0 @@
|
|||||||
// Sidebar nav collapse state — persisted in the `mxgateway_nav` cookie so it
|
|
||||||
// survives full page reloads and reconnects. Invoked from MainLayout.razor via
|
|
||||||
// JS interop (window.navState.get / .set). Pattern lifted from ScadaLink
|
|
||||||
// CentralUI's wwwroot/js/nav-state.js.
|
|
||||||
window.navState = {
|
|
||||||
// Returns the raw cookie value (comma-separated expanded section ids), or
|
|
||||||
// an empty string when the cookie is absent.
|
|
||||||
get: function () {
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)mxgateway_nav=([^;]*)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : "";
|
|
||||||
},
|
|
||||||
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
|
|
||||||
// (JS must write it) and not sensitive.
|
|
||||||
set: function (value) {
|
|
||||||
const oneYearSeconds = 60 * 60 * 24 * 365;
|
|
||||||
document.cookie = "mxgateway_nav=" + encodeURIComponent(value) +
|
|
||||||
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -87,7 +87,7 @@ public sealed class GatewayOptionsTests
|
|||||||
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
|
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
|
||||||
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
|
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
|
||||||
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
|
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
|
||||||
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Administrator' or 'Viewer'.")]
|
||||||
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
|
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
|
||||||
{
|
{
|
||||||
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
|
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
|
||||||
@@ -132,7 +132,7 @@ public sealed class GatewayOptionsTests
|
|||||||
|
|
||||||
ServiceCollection services = new();
|
ServiceCollection services = new();
|
||||||
services.AddSingleton<IConfiguration>(configuration);
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
services.AddGatewayConfiguration();
|
services.AddGatewayConfiguration(configuration);
|
||||||
|
|
||||||
return services.BuildServiceProvider(validateScopes: true);
|
return services.BuildServiceProvider(validateScopes: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
||||||
private static GatewayOptions ValidOptions() => new();
|
private static GatewayOptions ValidOptions() => new();
|
||||||
|
|
||||||
|
// Returns enabled LDAP options that pass all checks except Port.
|
||||||
|
// The class defaults already satisfy the blank-field checks; we only
|
||||||
|
// override Enabled (must be true to exercise the port check) and Port.
|
||||||
|
private static LdapOptions LdapOptionsWithPort(int port) => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Port = port,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Authentication = source.Authentication,
|
||||||
|
Ldap = ldap,
|
||||||
|
Worker = source.Worker,
|
||||||
|
Sessions = source.Sessions,
|
||||||
|
Events = source.Events,
|
||||||
|
Dashboard = source.Dashboard,
|
||||||
|
Protocol = source.Protocol,
|
||||||
|
Alarms = source.Alarms,
|
||||||
|
Tls = source.Tls,
|
||||||
|
};
|
||||||
|
|
||||||
private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
|
private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
@@ -65,4 +88,34 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
Assert.True(result.Failed);
|
Assert.True(result.Failed);
|
||||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
|
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenLdapPortIsZero()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(0));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 0)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenLdapPortExceedsMaximum()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(70000));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 70000)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WhenLdapEnabledWithValidPort()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(389));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public sealed class AuthStoreHealthCheckTests
|
||||||
|
{
|
||||||
|
private static AuthSqliteConnectionFactory FactoryFor(string sqlitePath)
|
||||||
|
{
|
||||||
|
// The shared connection factory targets a database path directly.
|
||||||
|
return new AuthSqliteConnectionFactory(sqlitePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Healthy_WhenStoreReachable()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var check = new AuthStoreHealthCheck(FactoryFor(path));
|
||||||
|
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||||
|
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||||
|
}
|
||||||
|
finally { if (File.Exists(path)) File.Delete(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unhealthy_WhenPathUnusable()
|
||||||
|
{
|
||||||
|
// A regular file used as a parent directory forces the open to fail.
|
||||||
|
var bogus = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}");
|
||||||
|
await File.WriteAllTextAsync(bogus, "x");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var check = new AuthStoreHealthCheck(FactoryFor(Path.Combine(bogus, "store.db")));
|
||||||
|
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||||
|
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||||
|
}
|
||||||
|
finally { if (File.Exists(bogus)) File.Delete(bogus); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class GatewayLogRedactorSeamTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Redact_MasksApiKeyInClientIdentity()
|
||||||
|
{
|
||||||
|
var redactor = new GatewayLogRedactorSeam();
|
||||||
|
var props = new Dictionary<string, object?> { ["ClientIdentity"] = "Bearer mxgw_operator01_super-secret" };
|
||||||
|
redactor.Redact(props);
|
||||||
|
Assert.Equal("Bearer mxgw_operator01_[redacted]", props["ClientIdentity"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
-204
@@ -1,21 +1,39 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||||
|
|
||||||
|
// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
|
||||||
|
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
public sealed class DashboardApiKeyManagementServiceTests
|
/// <summary>
|
||||||
|
/// Tests the gateway dashboard API-key management surface over the shared
|
||||||
|
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> admin commands and stores (the gateway is the donor). The service
|
||||||
|
/// is exercised against a real temporary SQLite store so the create/revoke/rotate/delete flow,
|
||||||
|
/// dashboard audit vocabulary, mxgw token format, duplicate-id rejection and revoke-before-delete
|
||||||
|
/// rule are all proven end-to-end.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DashboardApiKeyManagementServiceTests : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||||
|
|
||||||
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
|
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
|
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new();
|
await using ServiceProvider services = BuildServices();
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore);
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
||||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
@@ -23,17 +41,15 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(0, adminStore.CreateCount);
|
Assert.Empty(await ListAsync(services));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authorized users can create keys with secret hashing and audit trail.</summary>
|
/// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
|
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new();
|
await using ServiceProvider services = BuildServices();
|
||||||
FakeApiKeyAuditStore auditStore = new();
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
FakeApiKeySecretHasher hasher = new();
|
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
|
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
||||||
CreateAuthorizedUser(),
|
CreateAuthorizedUser(),
|
||||||
@@ -43,42 +59,49 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
Assert.NotNull(result.ApiKey);
|
Assert.NotNull(result.ApiKey);
|
||||||
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
||||||
string secret = result.ApiKey["mxgw_operator01_".Length..];
|
|
||||||
Assert.Equal(secret, hasher.LastSecret);
|
// The freshly minted token authenticates against the same store and surfaces its scopes.
|
||||||
Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal);
|
ApiKeyVerification verification = await services
|
||||||
ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests);
|
.GetRequiredService<IApiKeyVerifier>()
|
||||||
Assert.Equal("operator01", stored.KeyId);
|
.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None);
|
||||||
Assert.Equal("Operator", stored.DisplayName);
|
Assert.True(verification.Succeeded);
|
||||||
Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes);
|
Assert.Contains(GatewayScopes.SessionOpen, verification.Identity!.Scopes);
|
||||||
Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees);
|
|
||||||
Assert.Contains(auditStore.Entries, entry =>
|
// Constraints round-trip through the opaque JSON blob.
|
||||||
|
ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
|
||||||
|
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
|
||||||
|
|
||||||
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||||
|
// Phase 3: Actor = operator username ("alice"), Target = managed keyId ("operator01").
|
||||||
|
Assert.Contains(audit, entry =>
|
||||||
entry.EventType == "dashboard-create-key"
|
entry.EventType == "dashboard-create-key"
|
||||||
&& entry.KeyId == "operator01");
|
&& entry.KeyId == "alice");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that unauthorized users cannot revoke API keys.</summary>
|
/// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
|
public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new();
|
await using ServiceProvider services = BuildServices();
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore);
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
||||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
DashboardApiKeyManagementResult duplicate = await service.CreateAsync(
|
||||||
"operator01",
|
CreateAuthorizedUser(),
|
||||||
|
CreateRequest(),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(duplicate.Succeeded);
|
||||||
Assert.Equal(0, adminStore.RevokeCount);
|
Assert.Contains("already exists", duplicate.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
|
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
|
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new() { RevokeResult = true };
|
await using ServiceProvider services = BuildServices();
|
||||||
FakeApiKeyAuditStore auditStore = new();
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
||||||
CreateAuthorizedUser(),
|
CreateAuthorizedUser(),
|
||||||
@@ -86,21 +109,25 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
Assert.Equal("operator01", adminStore.LastRevokedKeyId);
|
ApiKeyListItem key = Assert.Single(await ListAsync(services));
|
||||||
Assert.Contains(auditStore.Entries, entry =>
|
Assert.NotNull(key.RevokedUtc);
|
||||||
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||||
|
// Phase 3: Actor = operator username; the dashboard-revoke-key event surfaces KeyId = "alice"
|
||||||
|
// (the operator) and Details = "revoked".
|
||||||
|
Assert.Contains(audit, entry =>
|
||||||
entry.EventType == "dashboard-revoke-key"
|
entry.EventType == "dashboard-revoke-key"
|
||||||
&& entry.KeyId == "operator01"
|
&& entry.KeyId == "alice"
|
||||||
&& entry.Details == "revoked");
|
&& entry.Details == "revoked");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authorized users can rotate secret hashes with audit trail.</summary>
|
/// <summary>Verifies that authorized users can rotate a key's secret with audit trail.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
|
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new() { RotateResult = true };
|
await using ServiceProvider services = BuildServices();
|
||||||
FakeApiKeyAuditStore auditStore = new();
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
FakeApiKeySecretHasher hasher = new();
|
DashboardApiKeyManagementResult created = await service.CreateAsync(
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
|
CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.RotateAsync(
|
DashboardApiKeyManagementResult result = await service.RotateAsync(
|
||||||
CreateAuthorizedUser(),
|
CreateAuthorizedUser(),
|
||||||
@@ -110,36 +137,29 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
Assert.NotNull(result.ApiKey);
|
Assert.NotNull(result.ApiKey);
|
||||||
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
||||||
Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash);
|
Assert.NotEqual(created.ApiKey, result.ApiKey);
|
||||||
Assert.Contains(auditStore.Entries, entry =>
|
|
||||||
|
// Old token no longer authenticates; new one does.
|
||||||
|
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
||||||
|
Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded);
|
||||||
|
Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);
|
||||||
|
|
||||||
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||||
|
// Phase 3: Actor = operator username ("alice").
|
||||||
|
Assert.Contains(audit, entry =>
|
||||||
entry.EventType == "dashboard-rotate-key"
|
entry.EventType == "dashboard-rotate-key"
|
||||||
&& entry.KeyId == "operator01"
|
&& entry.KeyId == "alice"
|
||||||
&& entry.Details == "rotated");
|
&& entry.Details == "rotated");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that unauthorized users cannot delete API keys.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
|
|
||||||
{
|
|
||||||
FakeApiKeyAdminStore adminStore = new();
|
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore);
|
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
|
||||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
|
||||||
"operator01",
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
|
||||||
Assert.Equal(0, adminStore.DeleteCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
|
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
|
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new() { DeleteResult = true };
|
await using ServiceProvider services = BuildServices();
|
||||||
FakeApiKeyAuditStore auditStore = new();
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
||||||
|
await service.RevokeAsync(CreateAuthorizedUser(), "operator01", CancellationToken.None);
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
||||||
CreateAuthorizedUser(),
|
CreateAuthorizedUser(),
|
||||||
@@ -147,27 +167,26 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
Assert.Equal("operator01", adminStore.LastDeletedKeyId);
|
Assert.Empty(await ListAsync(services));
|
||||||
Assert.Contains(auditStore.Entries, entry =>
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||||
|
// Phase 3: Actor = operator username ("alice").
|
||||||
|
Assert.Contains(audit, entry =>
|
||||||
entry.EventType == "dashboard-delete-key"
|
entry.EventType == "dashboard-delete-key"
|
||||||
&& entry.KeyId == "operator01"
|
&& entry.KeyId == "alice"
|
||||||
&& entry.Details == "deleted");
|
&& entry.Details == "deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests-030: when the admin store refuses the delete (returns <c>false</c>), the service
|
/// When the key is still active (not revoked), the delete is refused but a
|
||||||
/// still emits a <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c>
|
/// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
|
||||||
/// because <c>AppendAuditAsync</c> runs unconditionally after the store call. A regression that
|
/// written — audit completeness for refused deletes.
|
||||||
/// moved the audit-append call inside the <c>if (deleted)</c> branch would silently drop the
|
|
||||||
/// audit trail for refused deletes — a real audit-completeness gap. This test pins both the
|
|
||||||
/// friendly-error response AND the unconditional audit entry.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAsync_WhenStoreRefuses_ReportsFriendlyErrorAndAudits()
|
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new() { DeleteResult = false };
|
await using ServiceProvider services = BuildServices();
|
||||||
FakeApiKeyAuditStore auditStore = new();
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
|
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
||||||
CreateAuthorizedUser(),
|
CreateAuthorizedUser(),
|
||||||
@@ -177,17 +196,15 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
|
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
|
||||||
|
|
||||||
ApiKeyAuditEntry auditEntry = Assert.Single(auditStore.Entries);
|
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
|
||||||
Assert.Equal("dashboard-delete-key", auditEntry.EventType);
|
ApiKeyAuditEntry deleteEntry = Assert.Single(
|
||||||
Assert.Equal("operator01", auditEntry.KeyId);
|
audit, entry => entry.EventType == "dashboard-delete-key");
|
||||||
Assert.Equal("not-found-or-active", auditEntry.Details);
|
// Phase 3: Actor = operator username ("alice"), not the managed keyId.
|
||||||
|
Assert.Equal("alice", deleteEntry.KeyId);
|
||||||
|
Assert.Equal("not-found-or-active", deleteEntry.Details);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>A blank key id fails validation before any store or audit call runs.</summary>
|
||||||
/// Tests-030: <see cref="DashboardApiKeyManagementService.DeleteAsync"/> calls
|
|
||||||
/// <c>ValidateKeyId</c> after the authorisation check. A blank key id must fail with the
|
|
||||||
/// shared "API key id is required." message before any store or audit call runs.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
|
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
@@ -195,9 +212,8 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
[InlineData("\t")]
|
[InlineData("\t")]
|
||||||
public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
|
public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new();
|
await using ServiceProvider services = BuildServices();
|
||||||
FakeApiKeyAuditStore auditStore = new();
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
|
|
||||||
|
|
||||||
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
DashboardApiKeyManagementResult result = await service.DeleteAsync(
|
||||||
CreateAuthorizedUser(),
|
CreateAuthorizedUser(),
|
||||||
@@ -205,20 +221,19 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(0, adminStore.DeleteCount);
|
Assert.Empty(await ListAuditAsync(services));
|
||||||
Assert.Empty(auditStore.Entries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-004 regression: the dashboard create path must reject a request
|
/// Server-004 regression: the dashboard create path must reject a request carrying a
|
||||||
/// carrying a non-canonical scope string rather than persisting a key whose
|
/// non-canonical scope string rather than persisting a key whose scope the authorization
|
||||||
/// scope the authorization resolver never matches.
|
/// resolver never matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_UnknownScope_DoesNotCallStore()
|
public async Task CreateAsync_UnknownScope_DoesNotCreate()
|
||||||
{
|
{
|
||||||
FakeApiKeyAdminStore adminStore = new();
|
await using ServiceProvider services = BuildServices();
|
||||||
DashboardApiKeyManagementService service = CreateService(adminStore);
|
DashboardApiKeyManagementService service = CreateService(services);
|
||||||
|
|
||||||
DashboardApiKeyManagementRequest request = CreateRequest() with
|
DashboardApiKeyManagementRequest request = CreateRequest() with
|
||||||
{
|
{
|
||||||
@@ -233,25 +248,99 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(0, adminStore.CreateCount);
|
Assert.Empty(await ListAsync(services));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardApiKeyManagementService CreateService(
|
/// <summary>
|
||||||
FakeApiKeyAdminStore? adminStore = null,
|
/// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
|
||||||
FakeApiKeyAuditStore? auditStore = null,
|
/// the operator username as Actor and the managed keyId as Target.
|
||||||
FakeApiKeySecretHasher? hasher = null)
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
|
||||||
|
{
|
||||||
|
await using ServiceProvider services = BuildServices();
|
||||||
|
|
||||||
|
// Wire a recording writer so we can inspect the canonical AuditEvent directly (bypassing
|
||||||
|
// the CanonicalForwardingApiKeyAuditStore round-trip that ListAuditAsync uses).
|
||||||
|
RecordingAuditWriter recordingWriter = new();
|
||||||
|
DefaultHttpContext httpContext = new();
|
||||||
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
||||||
|
|
||||||
|
DashboardApiKeyManagementService service = new(
|
||||||
|
new DashboardApiKeyAuthorization(),
|
||||||
|
services.GetRequiredService<ApiKeyAdminCommands>(),
|
||||||
|
services.GetRequiredService<IApiKeyAdminStore>(),
|
||||||
|
recordingWriter,
|
||||||
|
new HttpContextAccessor { HttpContext = httpContext });
|
||||||
|
|
||||||
|
await service.CreateAsync(
|
||||||
|
CreateAuthorizedUser(),
|
||||||
|
CreateRequest(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// The dashboard-create-key event emitted directly by the service (not the library's
|
||||||
|
// create-key event forwarded via the adapter) must have Actor = operator username and
|
||||||
|
// Target = managed keyId.
|
||||||
|
ZB.MOM.WW.Audit.AuditEvent dashboardEvent = Assert.Single(
|
||||||
|
recordingWriter.Events,
|
||||||
|
e => e.Action == "dashboard-create-key");
|
||||||
|
Assert.Equal("alice", dashboardEvent.Actor);
|
||||||
|
Assert.Equal("operator01", dashboardEvent.Target);
|
||||||
|
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, dashboardEvent.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DashboardApiKeyManagementService CreateService(ServiceProvider services)
|
||||||
{
|
{
|
||||||
DefaultHttpContext httpContext = new();
|
DefaultHttpContext httpContext = new();
|
||||||
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
||||||
|
|
||||||
return new DashboardApiKeyManagementService(
|
return new DashboardApiKeyManagementService(
|
||||||
new DashboardApiKeyAuthorization(),
|
new DashboardApiKeyAuthorization(),
|
||||||
adminStore ?? new FakeApiKeyAdminStore(),
|
services.GetRequiredService<ApiKeyAdminCommands>(),
|
||||||
auditStore ?? new FakeApiKeyAuditStore(),
|
services.GetRequiredService<IApiKeyAdminStore>(),
|
||||||
hasher ?? new FakeApiKeySecretHasher(),
|
services.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
|
||||||
new HttpContextAccessor { HttpContext = httpContext });
|
new HttpContextAccessor { HttpContext = httpContext });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ServiceProvider BuildServices()
|
||||||
|
{
|
||||||
|
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-dashboard-apikey-tests");
|
||||||
|
_tempDirectories.Add(directory);
|
||||||
|
|
||||||
|
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(
|
||||||
|
new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(),
|
||||||
|
["MxGateway:ApiKeyPepper"] = "test-pepper"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
ServiceCollection services = new();
|
||||||
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
|
services.AddGatewayConfiguration(configuration);
|
||||||
|
services.AddSqliteAuthStore(configuration);
|
||||||
|
|
||||||
|
ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
|
||||||
|
|
||||||
|
// Production migrates the schema via the migration hosted service at startup; in these
|
||||||
|
// DI-only tests no host runs, so apply the (idempotent) migration up front.
|
||||||
|
provider.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>()
|
||||||
|
.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<IReadOnlyList<ApiKeyListItem>> ListAsync(ServiceProvider services)
|
||||||
|
{
|
||||||
|
return services.GetRequiredService<IApiKeyAdminStore>().ListAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<IReadOnlyList<ApiKeyAuditEntry>> ListAuditAsync(ServiceProvider services)
|
||||||
|
{
|
||||||
|
return services.GetRequiredService<IApiKeyAuditStore>().ListRecentAsync(50, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
private static DashboardApiKeyManagementRequest CreateRequest()
|
private static DashboardApiKeyManagementRequest CreateRequest()
|
||||||
{
|
{
|
||||||
return new DashboardApiKeyManagementRequest(
|
return new DashboardApiKeyManagementRequest(
|
||||||
@@ -266,8 +355,13 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
|
|
||||||
private static ClaimsPrincipal CreateAuthorizedUser()
|
private static ClaimsPrincipal CreateAuthorizedUser()
|
||||||
{
|
{
|
||||||
|
// Phase 3: include ZbClaimTypes.Username so ResolveOperatorActor picks up the LDAP
|
||||||
|
// login name ("alice") as the audit Actor. The keyId ("operator01") is the Target.
|
||||||
ClaimsIdentity identity = new(
|
ClaimsIdentity identity = new(
|
||||||
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
[
|
||||||
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
||||||
|
new Claim(ZbClaimTypes.Username, "alice"),
|
||||||
|
],
|
||||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
ClaimTypes.Name,
|
ClaimTypes.Name,
|
||||||
ClaimTypes.Role);
|
ClaimTypes.Role);
|
||||||
@@ -275,114 +369,28 @@ public sealed class DashboardApiKeyManagementServiceTests
|
|||||||
return new ClaimsPrincipal(identity);
|
return new ClaimsPrincipal(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
|
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
|
||||||
|
public void Dispose()
|
||||||
{
|
{
|
||||||
/// <summary>Gets the count of create operations performed.</summary>
|
foreach (TempDatabaseDirectory directory in _tempDirectories)
|
||||||
public int CreateCount { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Gets the count of revoke operations performed.</summary>
|
|
||||||
public int RevokeCount { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Gets the count of delete operations performed.</summary>
|
|
||||||
public int DeleteCount { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the result value returned by revoke operations.</summary>
|
|
||||||
public bool RevokeResult { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the result value returned by rotate operations.</summary>
|
|
||||||
public bool RotateResult { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the result value returned by delete operations.</summary>
|
|
||||||
public bool DeleteResult { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the last key ID revoked.</summary>
|
|
||||||
public string? LastRevokedKeyId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Gets the last key ID deleted.</summary>
|
|
||||||
public string? LastDeletedKeyId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Gets the last secret hash rotated.</summary>
|
|
||||||
public byte[]? LastRotatedSecretHash { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Gets the list of create requests received.</summary>
|
|
||||||
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
CreateCount++;
|
directory.Dispose();
|
||||||
CreatedRequests.Add(request);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
_tempDirectories.Clear();
|
||||||
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<bool> RevokeAsync(
|
|
||||||
string keyId,
|
|
||||||
DateTimeOffset revokedUtc,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
RevokeCount++;
|
|
||||||
LastRevokedKeyId = keyId;
|
|
||||||
return Task.FromResult(RevokeResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<bool> RotateAsync(
|
|
||||||
string keyId,
|
|
||||||
byte[] secretHash,
|
|
||||||
DateTimeOffset rotatedUtc,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
LastRotatedSecretHash = secretHash;
|
|
||||||
return Task.FromResult(RotateResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
DeleteCount++;
|
|
||||||
LastDeletedKeyId = keyId;
|
|
||||||
return Task.FromResult(DeleteResult);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
|
/// <summary>In-memory <see cref="ZB.MOM.WW.Audit.IAuditWriter"/> that records every event.</summary>
|
||||||
|
private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
|
||||||
{
|
{
|
||||||
/// <summary>Gets the list of audit entries appended.</summary>
|
/// <summary>Gets the recorded canonical audit events.</summary>
|
||||||
public List<ApiKeyAuditEntry> Entries { get; } = [];
|
public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Entries.Add(entry);
|
Events.Add(auditEvent);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
|
|
||||||
int count,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
|
|
||||||
{
|
|
||||||
/// <summary>Gets the last secret hashed.</summary>
|
|
||||||
public string? LastSecret { get; private set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public byte[] HashSecret(string secret)
|
|
||||||
{
|
|
||||||
LastSecret = secret;
|
|
||||||
return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,75 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parity tests for <see cref="DashboardAuthenticator"/> after the cutover to the
|
||||||
|
/// shared <see cref="ILdapAuthService"/>. The library now owns connect/bind/search
|
||||||
|
/// (covered by its own tests); these tests fake the shared service to return known
|
||||||
|
/// groups and assert the dashboard-specific policy is unchanged: groups → roles via
|
||||||
|
/// <see cref="IGroupRoleMapper{TRole}"/>, no-roles-matched is denied, and the
|
||||||
|
/// resulting principal/claims keep their exact shape.
|
||||||
|
/// </summary>
|
||||||
public sealed class DashboardAuthenticatorTests
|
public sealed class DashboardAuthenticatorTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
|
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
|
||||||
[Fact]
|
|
||||||
public void EscapeLdapFilter_EscapesSpecialCharacters()
|
|
||||||
{
|
|
||||||
string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f");
|
|
||||||
|
|
||||||
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that group-to-role mapping resolves by short name and distinguished name.</summary>
|
|
||||||
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
|
|
||||||
/// <param name="expectedRole">The expected role or null if no match.</param>
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
[InlineData(null)]
|
||||||
[InlineData("gwadmin", DashboardRoles.Admin)]
|
[InlineData("")]
|
||||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
|
[InlineData(" ")]
|
||||||
[InlineData("OtherGroup", null)]
|
public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
|
||||||
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
|
|
||||||
string ldapGroup,
|
|
||||||
string? expectedRole)
|
|
||||||
{
|
{
|
||||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||||
{
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
["GwReader"] = DashboardRoles.Viewer,
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping);
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
username,
|
||||||
|
"password",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
if (expectedRole is null)
|
Assert.False(result.Succeeded);
|
||||||
{
|
Assert.Null(result.Principal);
|
||||||
Assert.Empty(roles);
|
Assert.False(ldap.WasCalled);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Assert.Equal(expectedRole, Assert.Single(roles));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that admin and viewer roles are both emitted when groups are present.</summary>
|
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
|
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
|
||||||
{
|
{
|
||||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||||
{
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
["GwReader"] = DashboardRoles.Viewer,
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
["GwAdmin", "GwReader"],
|
"admin",
|
||||||
mapping);
|
" ",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.Contains(DashboardRoles.Admin, roles);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Contains(DashboardRoles.Viewer, roles);
|
Assert.Null(result.Principal);
|
||||||
|
Assert.False(ldap.WasCalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
|
/// <summary>
|
||||||
[Fact]
|
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
|
||||||
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
|
/// maps to the generic dashboard failure without leaking the raw password.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LdapAuthFailure.Disabled)]
|
||||||
|
[InlineData(LdapAuthFailure.BadCredentials)]
|
||||||
|
[InlineData(LdapAuthFailure.UserNotFound)]
|
||||||
|
[InlineData(LdapAuthFailure.ServiceAccountBindFailed)]
|
||||||
|
public async Task AuthenticateAsync_LdapFailure_ReturnsFailureWithoutRawCredentials(
|
||||||
|
LdapAuthFailure failure)
|
||||||
{
|
{
|
||||||
string result = DashboardAuthenticator.ExtractFirstRdnValue(
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
|
||||||
"CN=Gateway Admins,OU=Groups,DC=example,DC=com");
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
|
|
||||||
Assert.Equal("Gateway Admins", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
|
|
||||||
{
|
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions
|
|
||||||
{
|
|
||||||
Ldap = new LdapOptions
|
|
||||||
{
|
|
||||||
Enabled = false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -96,10 +81,230 @@ public sealed class DashboardAuthenticatorTests
|
|||||||
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options)
|
/// <summary>
|
||||||
|
/// On success the principal carries the resolved roles, the LDAP-group claims
|
||||||
|
/// (short RDN names as returned by <see cref="ILdapAuthService"/>), the display
|
||||||
|
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
|
||||||
|
/// dashboard authentication scheme.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
|
||||||
{
|
{
|
||||||
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||||
|
username: "admin",
|
||||||
|
displayName: "Administrator",
|
||||||
|
groups: ["GwAdmin", "GwReader"]));
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"admin",
|
||||||
|
"admin123",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||||
|
|
||||||
|
Assert.Equal("admin", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
Assert.Equal("Administrator", principal.FindFirst(ClaimTypes.Name)?.Value);
|
||||||
|
|
||||||
|
// Identity is built with the dashboard scheme and Name/Role claim types so
|
||||||
|
// IsInRole and Identity.Name keep working.
|
||||||
|
ClaimsIdentity identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
|
||||||
|
Assert.Equal(DashboardAuthenticationDefaults.AuthenticationScheme, identity.AuthenticationType);
|
||||||
|
Assert.Equal("Administrator", identity.Name);
|
||||||
|
|
||||||
|
// Both groups mapped → both roles present.
|
||||||
|
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
||||||
|
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
|
||||||
|
|
||||||
|
// LDAP groups (already short RDN names from the service) are surfaced under
|
||||||
|
// the dedicated group claim type.
|
||||||
|
IReadOnlyList<string> groupClaims = principal.FindAll(
|
||||||
|
DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||||
|
.Select(claim => claim.Value)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 1.5: the principal emits canonical ZbClaimTypes.Username ("zb:username") with
|
||||||
|
/// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and
|
||||||
|
/// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping
|
||||||
|
/// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims()
|
||||||
|
{
|
||||||
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||||
|
username: "jsmith",
|
||||||
|
displayName: "John Smith",
|
||||||
|
groups: ["GwAdmin", "GwReader"]));
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"jsmith",
|
||||||
|
"pw",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||||
|
|
||||||
|
// ZbClaimTypes.Username ("zb:username") carries the login username.
|
||||||
|
Assert.Equal("jsmith", principal.FindFirstValue(ZbClaimTypes.Username));
|
||||||
|
|
||||||
|
// ZbClaimTypes.DisplayName ("zb:displayname") carries the display name.
|
||||||
|
Assert.Equal("John Smith", principal.FindFirstValue(ZbClaimTypes.DisplayName));
|
||||||
|
|
||||||
|
// ZbClaimTypes.Role (= ClaimTypes.Role) is used for each role claim.
|
||||||
|
IReadOnlyList<string> roleClaims = principal
|
||||||
|
.FindAll(ZbClaimTypes.Role)
|
||||||
|
.Select(c => c.Value)
|
||||||
|
.OrderBy(r => r)
|
||||||
|
.ToList();
|
||||||
|
Assert.Contains(DashboardRoles.Admin, roleClaims);
|
||||||
|
Assert.Contains(DashboardRoles.Viewer, roleClaims);
|
||||||
|
|
||||||
|
// IsInRole still works (identity built with roleType = ZbClaimTypes.Role).
|
||||||
|
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
||||||
|
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
|
||||||
|
|
||||||
|
// ClaimTypes.Name still resolves as Identity.Name (nameType = ZbClaimTypes.Name = ClaimTypes.Name).
|
||||||
|
Assert.Equal("John Smith", principal.Identity?.Name);
|
||||||
|
|
||||||
|
// mxgateway:ldap_group claims are preserved.
|
||||||
|
IReadOnlyList<string> groups = principal
|
||||||
|
.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||||
|
.Select(c => c.Value)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(["GwAdmin", "GwReader"], groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the user authenticates but none of their groups map to a dashboard role,
|
||||||
|
/// the login is denied (the long-standing "no roles matched → denied" rule).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
|
||||||
|
{
|
||||||
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||||
|
username: "nobody",
|
||||||
|
displayName: "No Body",
|
||||||
|
groups: ["SomeUnmappedGroup"]));
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"nobody",
|
||||||
|
"pw",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.Null(result.Principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Direct-injection path only (review C1): when an <see cref="ILdapAuthService"/>
|
||||||
|
/// implementation hands the authenticator a full distinguished name as a group, the
|
||||||
|
/// mapper's leading-RDN fallback still resolves the role, and whatever string the
|
||||||
|
/// service supplied is surfaced verbatim on the group claim.
|
||||||
|
/// <para>
|
||||||
|
/// This is NOT the real production flow. The shared <c>ZB.MOM.WW.Auth.Ldap</c>
|
||||||
|
/// provider strips each group DN to its short RDN name before returning it, so the
|
||||||
|
/// authenticator never receives a full DN from the real library and the group claim
|
||||||
|
/// in production carries the short name (e.g. <c>GwAdmin</c>), not the DN. This test
|
||||||
|
/// uses a fake service to exercise only the authenticator's own pass-through of group
|
||||||
|
/// values; see <see cref="AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims"/>
|
||||||
|
/// for the realistic (already-short) group shape.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
|
||||||
|
{
|
||||||
|
const string groupDn = "ou=GwAdmin,ou=groups,dc=zb,dc=local";
|
||||||
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||||
|
username: "admin",
|
||||||
|
displayName: "admin",
|
||||||
|
groups: [groupDn]));
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"admin",
|
||||||
|
"admin123",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||||
|
// Role resolves via the leading-RDN fallback in DashboardGroupRoleMapping.
|
||||||
|
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
||||||
|
// The authenticator surfaces the value the (fake) service returned, verbatim.
|
||||||
|
// With the real library this value would already be the short RDN ("GwAdmin").
|
||||||
|
Assert.Contains(
|
||||||
|
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
|
||||||
|
claim => claim.Value == groupDn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
|
||||||
|
{
|
||||||
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||||
|
username: "canonical",
|
||||||
|
displayName: "Canonical User",
|
||||||
|
groups: ["GwReader"]));
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
" canonical ",
|
||||||
|
"pw",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||||
|
Assert.Equal("canonical", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
// The authenticator trims before delegating, so the provider sees the canonical value.
|
||||||
|
Assert.Equal("canonical", ldap.LastUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardAuthenticator CreateAuthenticator(
|
||||||
|
ILdapAuthService ldapAuthService,
|
||||||
|
Dictionary<string, string> groupToRole)
|
||||||
|
{
|
||||||
|
GatewayOptions options = new()
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
GroupToRole = groupToRole,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
IGroupRoleMapper<string> roleMapper = new DashboardGroupRoleMapper(Options.Create(options));
|
||||||
|
|
||||||
return new DashboardAuthenticator(
|
return new DashboardAuthenticator(
|
||||||
Options.Create(options),
|
ldapAuthService,
|
||||||
|
roleMapper,
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
NullLogger<DashboardAuthenticator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["GwAdmin"] = DashboardRoles.Admin,
|
||||||
|
["GwReader"] = DashboardRoles.Viewer,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake <see cref="ILdapAuthService"/> returning a fixed result, recording whether it was
|
||||||
|
/// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
|
||||||
|
{
|
||||||
|
public bool WasCalled { get; private set; }
|
||||||
|
|
||||||
|
public string? LastUsername { get; private set; }
|
||||||
|
|
||||||
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||||
|
{
|
||||||
|
WasCalled = true;
|
||||||
|
LastUsername = username;
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,4 +49,23 @@ public sealed class DashboardCookieOptionsTests
|
|||||||
|
|
||||||
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
|
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <c>MxGateway:Dashboard:CookieName</c> overrides the dashboard auth
|
||||||
|
/// cookie name, so a gateway instance sharing a hostname with another can be given a
|
||||||
|
/// distinct name (browser cookies are scoped by host+path, not port).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Build_WithCookieNameOverride_UsesConfiguredName()
|
||||||
|
{
|
||||||
|
await using WebApplication app = GatewayApplication.Build(
|
||||||
|
["--MxGateway:Dashboard:CookieName=MxGatewayDashboard.env2"]);
|
||||||
|
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||||
|
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||||
|
|
||||||
|
CookieAuthenticationOptions options = optionsMonitor.Get(
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
Assert.Equal("MxGatewayDashboard.env2", options.Cookie.Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="DashboardGroupRoleMapper"/>, the shared-Auth
|
||||||
|
/// <see cref="IGroupRoleMapper{TRole}"/> seam over the dashboard's
|
||||||
|
/// LDAP-group → role mapping. Behaviour must match the existing
|
||||||
|
/// <c>DashboardAuthenticator.MapGroupsToRoles</c> precedence/case rules.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DashboardGroupRoleMapperTests
|
||||||
|
{
|
||||||
|
private static DashboardGroupRoleMapper CreateMapper(Dictionary<string, string> mapping)
|
||||||
|
{
|
||||||
|
GatewayOptions options = new()
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
GroupToRole = mapping,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DashboardGroupRoleMapper(Options.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["GwAdmin"] = DashboardRoles.Admin,
|
||||||
|
["GwReader"] = DashboardRoles.Viewer,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
|
||||||
|
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
|
||||||
|
/// <param name="expectedRole">The expected role or null if no match.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
||||||
|
[InlineData("gwadmin", DashboardRoles.Admin)]
|
||||||
|
[InlineData("ou=GwAdmin,ou=groups,dc=zb,dc=local", DashboardRoles.Admin)]
|
||||||
|
[InlineData("OtherGroup", null)]
|
||||||
|
public async Task MapAsync_ResolvesByShortNameAndDistinguishedName(
|
||||||
|
string ldapGroup,
|
||||||
|
string? expectedRole)
|
||||||
|
{
|
||||||
|
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
|
||||||
|
|
||||||
|
GroupRoleMapping<string> result = await mapper.MapAsync([ldapGroup], CancellationToken.None);
|
||||||
|
|
||||||
|
if (expectedRole is null)
|
||||||
|
{
|
||||||
|
Assert.Empty(result.Roles);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Equal(expectedRole, Assert.Single(result.Roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Null(result.Scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that admin and viewer roles are both emitted when both groups are present.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MapAsync_AdminPlusViewer_BothRolesEmitted()
|
||||||
|
{
|
||||||
|
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
|
||||||
|
|
||||||
|
GroupRoleMapping<string> result = await mapper.MapAsync(
|
||||||
|
["GwAdmin", "GwReader"],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains(DashboardRoles.Admin, result.Roles);
|
||||||
|
Assert.Contains(DashboardRoles.Viewer, result.Roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an empty GroupToRole map yields no roles.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MapAsync_EmptyMapping_ReturnsNoRoles()
|
||||||
|
{
|
||||||
|
DashboardGroupRoleMapper mapper = CreateMapper(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
GroupRoleMapping<string> result = await mapper.MapAsync(["GwAdmin"], CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(result.Roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 1.7 (canonical roles): an LDAP user in the admin group must resolve
|
||||||
|
/// to the canonical role value <c>"Administrator"</c> (not the legacy
|
||||||
|
/// <c>"Admin"</c>), and the reader group to <c>"Viewer"</c>. Asserted with
|
||||||
|
/// string LITERALS — independent of <see cref="DashboardRoles"/> — so a
|
||||||
|
/// regression on the constant's value is caught here.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task MapAsync_AdminGroup_ResolvesToCanonicalAdministratorValue()
|
||||||
|
{
|
||||||
|
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
|
||||||
|
|
||||||
|
GroupRoleMapping<string> adminResult = await mapper.MapAsync(["GwAdmin"], CancellationToken.None);
|
||||||
|
GroupRoleMapping<string> readerResult = await mapper.MapAsync(["GwReader"], CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("Administrator", Assert.Single(adminResult.Roles));
|
||||||
|
Assert.Equal("Viewer", Assert.Single(readerResult.Roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 1.7: the canonical admin value (<c>"Administrator"</c>) passes the
|
||||||
|
/// admin-only gate while a <c>"Viewer"</c> fails it — asserted with literals,
|
||||||
|
/// proving enforcement is bound to the new value and the legacy <c>"Admin"</c>
|
||||||
|
/// string is no longer what authorizes admin actions.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AdminOnly_AuthorizesCanonicalAdministratorButNotViewer()
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> adminGate = DashboardAuthorizationRequirement.AdminOnly.RequiredRoles;
|
||||||
|
|
||||||
|
Assert.Contains("Administrator", adminGate);
|
||||||
|
Assert.DoesNotContain("Admin", adminGate);
|
||||||
|
Assert.DoesNotContain("Viewer", adminGate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the extracted shared helper is the single source of truth: it
|
||||||
|
/// produces the same roles the mapper does for the same inputs.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task SharedHelper_MatchesMapperOutput()
|
||||||
|
{
|
||||||
|
Dictionary<string, string> mapping = StandardMapping();
|
||||||
|
DashboardGroupRoleMapper mapper = CreateMapper(mapping);
|
||||||
|
string[] groups = ["ou=GwAdmin,ou=groups,dc=zb,dc=local", "gwreader"];
|
||||||
|
|
||||||
|
IReadOnlyList<string> helperRoles = DashboardGroupRoleMapping.MapGroupsToRoles(groups, mapping);
|
||||||
|
GroupRoleMapping<string> mapperResult = await mapper.MapAsync(groups, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal([.. helperRoles.OrderBy(r => r)], [.. mapperResult.Roles.OrderBy(r => r)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -273,16 +274,15 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
{
|
{
|
||||||
using GatewayMetrics metrics = new();
|
using GatewayMetrics metrics = new();
|
||||||
CountingApiKeyAdminStore apiKeyAdminStore = new(
|
CountingApiKeyAdminStore apiKeyAdminStore = new(
|
||||||
new ApiKeyRecord(
|
new ApiKeyListItem(
|
||||||
KeyId: "operator01",
|
KeyId: "operator01",
|
||||||
KeyPrefix: "mxgw_operator01",
|
KeyPrefix: "mxgw",
|
||||||
SecretHash: [1, 2, 3],
|
|
||||||
DisplayName: "Operator",
|
DisplayName: "Operator",
|
||||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||||
Constraints: ApiKeyConstraints.Empty with
|
ConstraintsJson: ApiKeyConstraintSerializer.Serialize(ApiKeyConstraints.Empty with
|
||||||
{
|
{
|
||||||
BrowseSubtrees = ["Area1/*"],
|
BrowseSubtrees = ["Area1/*"],
|
||||||
},
|
}),
|
||||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||||
LastUsedUtc: null,
|
LastUsedUtc: null,
|
||||||
RevokedUtc: null));
|
RevokedUtc: null));
|
||||||
@@ -310,13 +310,12 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
{
|
{
|
||||||
using GatewayMetrics metrics = new();
|
using GatewayMetrics metrics = new();
|
||||||
SequencedApiKeyAdminStore apiKeyAdminStore = new(
|
SequencedApiKeyAdminStore apiKeyAdminStore = new(
|
||||||
new ApiKeyRecord(
|
new ApiKeyListItem(
|
||||||
KeyId: "operator01",
|
KeyId: "operator01",
|
||||||
KeyPrefix: "mxgw_operator01",
|
KeyPrefix: "mxgw",
|
||||||
SecretHash: [1, 2, 3],
|
|
||||||
DisplayName: "Operator",
|
DisplayName: "Operator",
|
||||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||||
Constraints: ApiKeyConstraints.Empty,
|
ConstraintsJson: null,
|
||||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||||
LastUsedUtc: null,
|
LastUsedUtc: null,
|
||||||
RevokedUtc: null));
|
RevokedUtc: null));
|
||||||
@@ -425,63 +424,56 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
private class FakeApiKeyAdminStore : IApiKeyAdminStore
|
private class FakeApiKeyAdminStore : IApiKeyAdminStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
public Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
public virtual Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
return Task.FromResult<IReadOnlyList<ApiKeyListItem>>([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<bool> RevokeAsync(
|
public Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||||
string keyId,
|
|
||||||
DateTimeOffset revokedUtc,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<bool> RotateAsync(
|
public Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
|
||||||
string keyId,
|
|
||||||
byte[] secretHash,
|
|
||||||
DateTimeOffset rotatedUtc,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
|
public Task<bool> DeleteAsync(string keyId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
|
private class CountingApiKeyAdminStore(params ApiKeyListItem[] records) : FakeApiKeyAdminStore
|
||||||
{
|
{
|
||||||
/// <summary>Gets the count of list operations performed.</summary>
|
/// <summary>Gets the count of list operations performed.</summary>
|
||||||
public int ListCount { get; protected set; }
|
public int ListCount { get; protected set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
ListCount++;
|
ListCount++;
|
||||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
|
return Task.FromResult<IReadOnlyList<ApiKeyListItem>>(records);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
|
private sealed class SequencedApiKeyAdminStore(ApiKeyListItem record) : CountingApiKeyAdminStore(record)
|
||||||
{
|
{
|
||||||
/// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
|
/// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
|
||||||
public bool FailNext { get; set; }
|
public bool FailNext { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (FailNext)
|
if (FailNext)
|
||||||
{
|
{
|
||||||
@@ -490,7 +482,7 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
throw new InvalidOperationException("Simulated SQLite failure.");
|
throw new InvalidOperationException("Simulated SQLite failure.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return base.ListAsync(cancellationToken);
|
return base.ListAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.MxGateway.Server;
|
using ZB.MOM.WW.MxGateway.Server;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -11,19 +14,31 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
|||||||
|
|
||||||
public sealed class GatewayApplicationTests
|
public sealed class GatewayApplicationTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
|
/// <summary>Verifies that Build maps the canonical three health tiers.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Build_MapsLiveHealthEndpoint()
|
public async Task Build_MapsCanonicalHealthEndpoints()
|
||||||
{
|
{
|
||||||
await using WebApplication app = GatewayApplication.Build([]);
|
await using WebApplication app = GatewayApplication.Build([]);
|
||||||
|
|
||||||
RouteEndpoint endpoint = Assert.Single(
|
var paths = ((IEndpointRouteBuilder)app).DataSources
|
||||||
((IEndpointRouteBuilder)app).DataSources
|
.SelectMany(dataSource => dataSource.Endpoints)
|
||||||
.SelectMany(dataSource => dataSource.Endpoints)
|
.OfType<RouteEndpoint>()
|
||||||
.OfType<RouteEndpoint>(),
|
.Select(e => e.RoutePattern.RawText)
|
||||||
candidate => candidate.RoutePattern.RawText == "/health/live");
|
.ToHashSet();
|
||||||
|
|
||||||
Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName);
|
Assert.Contains("/health/ready", paths);
|
||||||
|
Assert.Contains("/health/active", paths);
|
||||||
|
Assert.Contains("/healthz", paths);
|
||||||
|
Assert.DoesNotContain("/health/live", paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that Build registers Serilog as the host logging provider.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Build_UsesSerilogLoggerProvider()
|
||||||
|
{
|
||||||
|
using var app = GatewayApplication.Build([]);
|
||||||
|
var factory = app.Services.GetRequiredService<ILoggerFactory>();
|
||||||
|
Assert.Equal("SerilogLoggerFactory", factory.GetType().Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
|
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
|
||||||
@@ -37,6 +52,28 @@ public sealed class GatewayApplicationTests
|
|||||||
Assert.NotNull(metrics);
|
Assert.NotNull(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that Build mounts the Prometheus /metrics scrape endpoint.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Build_MapsMetricsEndpoint()
|
||||||
|
{
|
||||||
|
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
||||||
|
// started-host test must avoid a fixed port to prevent a bind collision.
|
||||||
|
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
|
||||||
|
await app.StartAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
|
||||||
|
|
||||||
|
using HttpResponseMessage response = await client.GetAsync("/metrics");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await app.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
||||||
@@ -49,8 +86,19 @@ public sealed class GatewayApplicationTests
|
|||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
||||||
|
|
||||||
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
||||||
|
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
|
||||||
|
// endpoint. The credential POST goes to the DashboardLoginPost endpoint at
|
||||||
|
// /auth/login — a DISTINCT route. The Blazor component endpoint matches all HTTP
|
||||||
|
// methods, so sharing the "/login" route with MapPost previously made POST /login
|
||||||
|
// ambiguous (AmbiguousMatchException → HTTP 500). Pinning the POST to /auth/login
|
||||||
|
// is the regression guard for that fix.
|
||||||
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
|
||||||
|
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost"
|
||||||
|
&& endpoint.RoutePattern.RawText == "/auth/login");
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
||||||
}
|
}
|
||||||
@@ -63,7 +111,7 @@ public sealed class GatewayApplicationTests
|
|||||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||||
|
|
||||||
string[] anonymousEndpointNames =
|
string[] anonymousEndpointNames =
|
||||||
["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"];
|
["DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"];
|
||||||
foreach (string endpointName in anonymousEndpointNames)
|
foreach (string endpointName in anonymousEndpointNames)
|
||||||
{
|
{
|
||||||
RouteEndpoint endpoint = Assert.Single(
|
RouteEndpoint endpoint = Assert.Single(
|
||||||
@@ -72,6 +120,16 @@ public sealed class GatewayApplicationTests
|
|||||||
|
|
||||||
Assert.NotNull(endpoint.Metadata.GetMetadata<IAllowAnonymous>());
|
Assert.NotNull(endpoint.Metadata.GetMetadata<IAllowAnonymous>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /login is the [AllowAnonymous] Blazor <Login> component route. Its
|
||||||
|
// [AllowAnonymous] attribute overrides the RequireAuthorization(ViewerPolicy)
|
||||||
|
// that MapRazorComponents<App>() applies, so the LoginPath="/login" redirect
|
||||||
|
// resolves for unauthenticated users instead of looping the cookie challenge.
|
||||||
|
RouteEndpoint loginComponent = Assert.Single(
|
||||||
|
endpoints,
|
||||||
|
candidate => candidate.RoutePattern.RawText == "/login"
|
||||||
|
&& candidate.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||||
|
Assert.NotNull(loginComponent.Metadata.GetMetadata<IAllowAnonymous>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
||||||
@@ -160,11 +218,11 @@ public sealed class GatewayApplicationTests
|
|||||||
[InlineData(
|
[InlineData(
|
||||||
"MxGateway:Dashboard:GroupToRole:GwAdmin",
|
"MxGateway:Dashboard:GroupToRole:GwAdmin",
|
||||||
"BogusRole",
|
"BogusRole",
|
||||||
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Administrator' or 'Viewer'.")]
|
||||||
[InlineData(
|
[InlineData(
|
||||||
"MxGateway:Ldap:AllowInsecureLdap",
|
"MxGateway:Ldap:AllowInsecure",
|
||||||
"false",
|
"false",
|
||||||
"MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")]
|
"MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).")]
|
||||||
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
|
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
|
||||||
string key,
|
string key,
|
||||||
string value,
|
string value,
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests the Task 2.3 canonical audit plumbing: the gateway-owned
|
||||||
|
/// <see cref="SqliteCanonicalAuditStore"/> (round-trips canonical
|
||||||
|
/// <see cref="AuditEvent"/>s through the new <c>audit_event</c> table), the best-effort
|
||||||
|
/// <see cref="CanonicalAuditWriter"/>, and the <see cref="CanonicalForwardingApiKeyAuditStore"/>
|
||||||
|
/// adapter that maps the library's <see cref="ApiKeyAuditEntry"/> onto the canonical model
|
||||||
|
/// and back (so the dashboard recent-audit view keeps working). All against a real
|
||||||
|
/// temporary SQLite database, sharing the library's connection factory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||||
|
|
||||||
|
/// <summary>A canonical event with all fields populated round-trips through the store.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Store_InsertThenListRecent_RoundTripsAllFields()
|
||||||
|
{
|
||||||
|
SqliteCanonicalAuditStore store = CreateStore();
|
||||||
|
|
||||||
|
Guid eventId = Guid.NewGuid();
|
||||||
|
Guid correlationId = Guid.NewGuid();
|
||||||
|
DateTimeOffset occurred = new(2026, 6, 1, 12, 34, 56, TimeSpan.Zero);
|
||||||
|
AuditEvent original = new()
|
||||||
|
{
|
||||||
|
EventId = eventId,
|
||||||
|
OccurredAtUtc = occurred,
|
||||||
|
Actor = "operator01",
|
||||||
|
Action = "dashboard-create-key",
|
||||||
|
Outcome = AuditOutcome.Success,
|
||||||
|
Category = "ApiKey",
|
||||||
|
Target = "operator01",
|
||||||
|
SourceNode = "127.0.0.1",
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
DetailsJson = "{\"detail\":\"created\"}",
|
||||||
|
};
|
||||||
|
|
||||||
|
await store.InsertAsync(original, CancellationToken.None);
|
||||||
|
|
||||||
|
IReadOnlyList<AuditEvent> recent = await store.ListRecentAsync(10, CancellationToken.None);
|
||||||
|
|
||||||
|
AuditEvent persisted = Assert.Single(recent);
|
||||||
|
Assert.Equal(eventId, persisted.EventId);
|
||||||
|
Assert.Equal(occurred, persisted.OccurredAtUtc);
|
||||||
|
Assert.Equal("operator01", persisted.Actor);
|
||||||
|
Assert.Equal("dashboard-create-key", persisted.Action);
|
||||||
|
Assert.Equal(AuditOutcome.Success, persisted.Outcome);
|
||||||
|
Assert.Equal("ApiKey", persisted.Category);
|
||||||
|
Assert.Equal("operator01", persisted.Target);
|
||||||
|
Assert.Equal("127.0.0.1", persisted.SourceNode);
|
||||||
|
Assert.Equal(correlationId, persisted.CorrelationId);
|
||||||
|
Assert.Equal("{\"detail\":\"created\"}", persisted.DetailsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Nullable canonical fields round-trip as null, not empty string.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Store_InsertWithNullOptionalFields_RoundTripsAsNull()
|
||||||
|
{
|
||||||
|
SqliteCanonicalAuditStore store = CreateStore();
|
||||||
|
|
||||||
|
AuditEvent original = new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
Actor = "system",
|
||||||
|
Action = "init-db",
|
||||||
|
Outcome = AuditOutcome.Success,
|
||||||
|
};
|
||||||
|
|
||||||
|
await store.InsertAsync(original, CancellationToken.None);
|
||||||
|
|
||||||
|
AuditEvent persisted = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Null(persisted.Category);
|
||||||
|
Assert.Null(persisted.Target);
|
||||||
|
Assert.Null(persisted.SourceNode);
|
||||||
|
Assert.Null(persisted.CorrelationId);
|
||||||
|
Assert.Null(persisted.DetailsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ListRecent returns newest-first.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Store_ListRecent_ReturnsNewestFirst()
|
||||||
|
{
|
||||||
|
SqliteCanonicalAuditStore store = CreateStore();
|
||||||
|
|
||||||
|
await store.InsertAsync(MakeEvent("first"), CancellationToken.None);
|
||||||
|
await store.InsertAsync(MakeEvent("second"), CancellationToken.None);
|
||||||
|
await store.InsertAsync(MakeEvent("third"), CancellationToken.None);
|
||||||
|
|
||||||
|
IReadOnlyList<AuditEvent> recent = await store.ListRecentAsync(10, CancellationToken.None);
|
||||||
|
Assert.Equal(["third", "second", "first"], recent.Select(e => e.Action));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The writer is best-effort: a faulting store does not surface to the caller.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Writer_WhenStoreFails_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// Point the connection factory at a path that cannot be created (a file used as a
|
||||||
|
// directory) so OpenConnectionAsync/insert fails; the writer must swallow it.
|
||||||
|
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-canonical-audit-fail");
|
||||||
|
_tempDirectories.Add(directory);
|
||||||
|
string filePath = directory.DatabasePath("blocker");
|
||||||
|
File.WriteAllText(filePath, "not a directory");
|
||||||
|
// Nested path under a regular file → directory creation / open fails.
|
||||||
|
AuthSqliteConnectionFactory factory = new(Path.Combine(filePath, "audit.db"));
|
||||||
|
CanonicalAuditWriter writer = new(
|
||||||
|
new SqliteCanonicalAuditStore(factory),
|
||||||
|
NullLogger<CanonicalAuditWriter>.Instance);
|
||||||
|
|
||||||
|
// Must not throw.
|
||||||
|
await writer.WriteAsync(MakeEvent("anything"), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A library event with a KeyId maps to a canonical Success event under category ApiKey,
|
||||||
|
/// and the adapter maps it back to the original entry for the dashboard view.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Adapter_KeyedEvent_RoundTripsThroughCanonicalStore()
|
||||||
|
{
|
||||||
|
(CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
|
||||||
|
DateTimeOffset created = new(2026, 6, 1, 9, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
await adapter.AppendAsync(
|
||||||
|
new ApiKeyAuditEntry(
|
||||||
|
KeyId: "operator01",
|
||||||
|
EventType: "create-key",
|
||||||
|
RemoteAddress: "10.0.0.5",
|
||||||
|
CreatedUtc: created,
|
||||||
|
Details: "created"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Canonical persisted form.
|
||||||
|
AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Equal("operator01", canonical.Actor);
|
||||||
|
Assert.Equal("create-key", canonical.Action);
|
||||||
|
Assert.Equal(AuditOutcome.Success, canonical.Outcome);
|
||||||
|
Assert.Equal(CanonicalForwardingApiKeyAuditStore.ApiKeyCategory, canonical.Category);
|
||||||
|
Assert.Equal("operator01", canonical.Target);
|
||||||
|
Assert.Equal("10.0.0.5", canonical.SourceNode);
|
||||||
|
Assert.Equal("{\"detail\":\"created\"}", canonical.DetailsJson);
|
||||||
|
|
||||||
|
// Dashboard-facing form via the adapter's ListRecentAsync.
|
||||||
|
ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Equal("operator01", mappedBack.KeyId);
|
||||||
|
Assert.Equal("create-key", mappedBack.EventType);
|
||||||
|
Assert.Equal("10.0.0.5", mappedBack.RemoteAddress);
|
||||||
|
Assert.Equal(created, mappedBack.CreatedUtc);
|
||||||
|
Assert.Equal("created", mappedBack.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The keyless library <c>init-db</c> event maps to Actor "system" and back to a null KeyId.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Adapter_InitDbKeylessEvent_MapsToSystemActor()
|
||||||
|
{
|
||||||
|
(CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
|
||||||
|
|
||||||
|
await adapter.AppendAsync(
|
||||||
|
new ApiKeyAuditEntry(
|
||||||
|
KeyId: null,
|
||||||
|
EventType: "init-db",
|
||||||
|
RemoteAddress: null,
|
||||||
|
CreatedUtc: DateTimeOffset.UtcNow,
|
||||||
|
Details: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Equal("system", canonical.Actor);
|
||||||
|
Assert.Equal(AuditOutcome.Success, canonical.Outcome);
|
||||||
|
Assert.Null(canonical.DetailsJson);
|
||||||
|
|
||||||
|
ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Null(mappedBack.KeyId);
|
||||||
|
Assert.Equal("init-db", mappedBack.EventType);
|
||||||
|
Assert.Null(mappedBack.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Any other keyless library event maps to Actor "cli" and back to a null KeyId.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Adapter_OtherKeylessEvent_MapsToCliActor()
|
||||||
|
{
|
||||||
|
(CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
|
||||||
|
|
||||||
|
await adapter.AppendAsync(
|
||||||
|
new ApiKeyAuditEntry(
|
||||||
|
KeyId: null,
|
||||||
|
EventType: "list-keys",
|
||||||
|
RemoteAddress: null,
|
||||||
|
CreatedUtc: DateTimeOffset.UtcNow,
|
||||||
|
Details: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Equal("cli", canonical.Actor);
|
||||||
|
|
||||||
|
ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Null(mappedBack.KeyId);
|
||||||
|
Assert.Equal("list-keys", mappedBack.EventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A <c>constraint-denied</c> library event maps to Outcome.Denied.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Adapter_ConstraintDeniedEvent_MapsToDeniedOutcome()
|
||||||
|
{
|
||||||
|
(CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
|
||||||
|
|
||||||
|
await adapter.AppendAsync(
|
||||||
|
new ApiKeyAuditEntry(
|
||||||
|
KeyId: "operator01",
|
||||||
|
EventType: "constraint-denied",
|
||||||
|
RemoteAddress: null,
|
||||||
|
CreatedUtc: DateTimeOffset.UtcNow,
|
||||||
|
Details: "Write: 42: write_scope: outside scope"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Equal(AuditOutcome.Denied, canonical.Outcome);
|
||||||
|
Assert.Equal("operator01", canonical.Actor);
|
||||||
|
|
||||||
|
ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
|
||||||
|
Assert.Equal("constraint-denied", mappedBack.EventType);
|
||||||
|
Assert.Equal("Write: 42: write_scope: outside scope", mappedBack.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The adapter does NOT throw when the underlying write fails (it forwards through the
|
||||||
|
/// best-effort writer), preserving the IApiKeyAuditStore caller's flow.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Adapter_WhenWriterFails_DoesNotThrow()
|
||||||
|
{
|
||||||
|
string badPath = Path.Combine(Path.GetTempPath(), "mxgateway-canonical-audit-fail", Guid.NewGuid().ToString("N"), "blocker");
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(badPath)!);
|
||||||
|
File.WriteAllText(badPath, "not a directory");
|
||||||
|
AuthSqliteConnectionFactory factory = new(Path.Combine(badPath, "audit.db"));
|
||||||
|
SqliteCanonicalAuditStore store = new(factory);
|
||||||
|
CanonicalAuditWriter writer = new(store, NullLogger<CanonicalAuditWriter>.Instance);
|
||||||
|
CanonicalForwardingApiKeyAuditStore adapter = new(writer, store);
|
||||||
|
|
||||||
|
await adapter.AppendAsync(
|
||||||
|
new ApiKeyAuditEntry("k", "create-key", null, DateTimeOffset.UtcNow, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteCanonicalAuditStore CreateStore()
|
||||||
|
{
|
||||||
|
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-canonical-audit");
|
||||||
|
_tempDirectories.Add(directory);
|
||||||
|
return new SqliteCanonicalAuditStore(new AuthSqliteConnectionFactory(directory.DatabasePath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private (CanonicalForwardingApiKeyAuditStore Adapter, SqliteCanonicalAuditStore Store) CreateAdapter()
|
||||||
|
{
|
||||||
|
SqliteCanonicalAuditStore store = CreateStore();
|
||||||
|
CanonicalAuditWriter writer = new(store, NullLogger<CanonicalAuditWriter>.Instance);
|
||||||
|
return (new CanonicalForwardingApiKeyAuditStore(writer, store), store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEvent MakeEvent(string action) => new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
Actor = "operator01",
|
||||||
|
Action = action,
|
||||||
|
Outcome = AuditOutcome.Success,
|
||||||
|
Category = "ApiKey",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (TempDatabaseDirectory directory in _tempDirectories)
|
||||||
|
{
|
||||||
|
directory.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_tempDirectories.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests <see cref="HttpAuditActorAccessor"/> — the HTTP-backed <see cref="IAuditActorAccessor"/>
|
||||||
|
/// that reads the dashboard operator's username from the current HTTP context.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HttpAuditActorAccessorTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When the HTTP context carries an authenticated principal with <see cref="ZbClaimTypes.Username"/>,
|
||||||
|
/// <see cref="IAuditActorAccessor.CurrentActor"/> returns that username.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CurrentActor_AuthenticatedUserWithUsernamelaim_ReturnsUsername()
|
||||||
|
{
|
||||||
|
HttpAuditActorAccessor accessor = CreateAccessor(
|
||||||
|
CreateAuthenticatedUser(username: "alice", displayName: "Alice Admin"));
|
||||||
|
|
||||||
|
Assert.Equal("alice", accessor.CurrentActor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the principal has no <see cref="ZbClaimTypes.Username"/> but has a
|
||||||
|
/// <see cref="ClaimTypes.Name"/> claim (display name), the accessor falls back to
|
||||||
|
/// <see cref="System.Security.Principal.IIdentity.Name"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CurrentActor_AuthenticatedUserWithoutUsernameClaim_FallsBackToIdentityName()
|
||||||
|
{
|
||||||
|
// Build a principal with display-name but no zb:username.
|
||||||
|
ClaimsIdentity identity = new(
|
||||||
|
[new Claim(ClaimTypes.Name, "Alice Admin")],
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
ClaimTypes.Name,
|
||||||
|
ClaimTypes.Role);
|
||||||
|
HttpAuditActorAccessor accessor = CreateAccessor(new ClaimsPrincipal(identity));
|
||||||
|
|
||||||
|
Assert.Equal("Alice Admin", accessor.CurrentActor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An unauthenticated (anonymous) principal yields <see langword="null"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CurrentActor_UnauthenticatedUser_ReturnsNull()
|
||||||
|
{
|
||||||
|
HttpAuditActorAccessor accessor = CreateAccessor(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
|
|
||||||
|
Assert.Null(accessor.CurrentActor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When there is no HTTP context at all (e.g. a background thread), the accessor returns
|
||||||
|
/// <see langword="null"/> rather than throwing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CurrentActor_NoHttpContext_ReturnsNull()
|
||||||
|
{
|
||||||
|
HttpContextAccessor contextAccessor = new() { HttpContext = null };
|
||||||
|
HttpAuditActorAccessor accessor = new(contextAccessor);
|
||||||
|
|
||||||
|
Assert.Null(accessor.CurrentActor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the <see cref="ZbClaimTypes.Username"/> claim is present it is always preferred
|
||||||
|
/// over the <see cref="ClaimTypes.Name"/> (display-name) value.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CurrentActor_UsernameClaimPreferredOverDisplayName()
|
||||||
|
{
|
||||||
|
// login username = "jsmith"; display name = "John Smith"
|
||||||
|
HttpAuditActorAccessor accessor = CreateAccessor(
|
||||||
|
CreateAuthenticatedUser(username: "jsmith", displayName: "John Smith"));
|
||||||
|
|
||||||
|
Assert.Equal("jsmith", accessor.CurrentActor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static HttpAuditActorAccessor CreateAccessor(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
DefaultHttpContext httpContext = new() { User = user };
|
||||||
|
return new HttpAuditActorAccessor(new HttpContextAccessor { HttpContext = httpContext });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal CreateAuthenticatedUser(string username, string displayName)
|
||||||
|
{
|
||||||
|
ClaimsIdentity identity = new(
|
||||||
|
[
|
||||||
|
new Claim(ZbClaimTypes.Username, username),
|
||||||
|
new Claim(ZbClaimTypes.Name, displayName),
|
||||||
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
||||||
|
],
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
ZbClaimTypes.Name,
|
||||||
|
ZbClaimTypes.Role);
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-13
@@ -1,9 +1,13 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
|
||||||
|
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||||
|
|
||||||
public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||||
@@ -33,14 +37,14 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
|||||||
string apiKey = ReadApiKey(output.ToString());
|
string apiKey = ReadApiKey(output.ToString());
|
||||||
|
|
||||||
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
||||||
ApiKeyVerificationResult verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
ApiKeyVerification verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(verification.Succeeded);
|
Assert.True(verification.Succeeded);
|
||||||
Assert.NotNull(verification.Identity);
|
Assert.NotNull(verification.Identity);
|
||||||
Assert.Equal("operator01", verification.Identity.KeyId);
|
Assert.Equal("operator01", verification.Identity.KeyId);
|
||||||
Assert.Contains("session:open", verification.Identity.Scopes);
|
Assert.Contains("session:open", verification.Identity.Scopes);
|
||||||
|
|
||||||
IReadOnlyList<ApiKeyAuditRecord> auditRecords = await services
|
IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
|
||||||
.GetRequiredService<IApiKeyAuditStore>()
|
.GetRequiredService<IApiKeyAuditStore>()
|
||||||
.ListRecentAsync(10, CancellationToken.None);
|
.ListRecentAsync(10, CancellationToken.None);
|
||||||
|
|
||||||
@@ -98,14 +102,14 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
|||||||
TextWriter.Null,
|
TextWriter.Null,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
ApiKeyVerificationResult verification = await services
|
ApiKeyVerification verification = await services
|
||||||
.GetRequiredService<IApiKeyVerifier>()
|
.GetRequiredService<IApiKeyVerifier>()
|
||||||
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(verification.Succeeded);
|
Assert.False(verification.Succeeded);
|
||||||
Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure);
|
Assert.Equal(ApiKeyFailure.KeyRevoked, verification.Failure);
|
||||||
|
|
||||||
IReadOnlyList<ApiKeyAuditRecord> auditRecords = await services
|
IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
|
||||||
.GetRequiredService<IApiKeyAuditStore>()
|
.GetRequiredService<IApiKeyAuditStore>()
|
||||||
.ListRecentAsync(10, CancellationToken.None);
|
.ListRecentAsync(10, CancellationToken.None);
|
||||||
|
|
||||||
@@ -141,11 +145,11 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
|||||||
Assert.Equal(1, CountOccurrences(rotateJson, newApiKey));
|
Assert.Equal(1, CountOccurrences(rotateJson, newApiKey));
|
||||||
|
|
||||||
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
||||||
ApiKeyVerificationResult oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
|
ApiKeyVerification oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
|
||||||
ApiKeyVerificationResult newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
|
ApiKeyVerification newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(oldVerification.Succeeded);
|
Assert.False(oldVerification.Succeeded);
|
||||||
Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, oldVerification.Failure);
|
Assert.Equal(ApiKeyFailure.SecretMismatch, oldVerification.Failure);
|
||||||
Assert.True(newVerification.Succeeded);
|
Assert.True(newVerification.Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +207,16 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
string apiKey = ReadApiKey(output.ToString());
|
string apiKey = ReadApiKey(output.ToString());
|
||||||
ApiKeyVerificationResult verification = await services
|
ApiKeyVerification verification = await services
|
||||||
.GetRequiredService<IApiKeyVerifier>()
|
.GetRequiredService<IApiKeyVerifier>()
|
||||||
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(verification.Succeeded);
|
Assert.True(verification.Succeeded);
|
||||||
Assert.Equal(["Area1/*"], verification.Identity!.EffectiveConstraints.BrowseSubtrees);
|
// The shared verifier returns the opaque constraints JSON; map it to the gateway identity so
|
||||||
Assert.True(verification.Identity.EffectiveConstraints.ReadAlarmOnly);
|
// the strongly-typed effective constraints round-trip can be asserted.
|
||||||
|
ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity!);
|
||||||
|
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
|
||||||
|
Assert.True(gatewayIdentity.EffectiveConstraints.ReadAlarmOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -245,8 +252,8 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
|||||||
|
|
||||||
ServiceCollection services = new();
|
ServiceCollection services = new();
|
||||||
services.AddSingleton<IConfiguration>(configuration);
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
services.AddGatewayConfiguration();
|
services.AddGatewayConfiguration(configuration);
|
||||||
services.AddSqliteAuthStore();
|
services.AddSqliteAuthStore(configuration);
|
||||||
|
|
||||||
return services.BuildServiceProvider(validateScopes: true);
|
return services.BuildServiceProvider(validateScopes: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class ApiKeyParserTests
|
|
||||||
{
|
|
||||||
/// <summary>Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void TryParseAuthorizationHeader_ValidBearerToken_ReturnsKeyIdAndSecret()
|
|
||||||
{
|
|
||||||
ApiKeyParser parser = new();
|
|
||||||
|
|
||||||
bool parsed = parser.TryParseAuthorizationHeader(
|
|
||||||
"Bearer mxgw_operator01_secret_value",
|
|
||||||
out ParsedApiKey? apiKey);
|
|
||||||
|
|
||||||
Assert.True(parsed);
|
|
||||||
Assert.NotNull(apiKey);
|
|
||||||
Assert.Equal("operator01", apiKey.KeyId);
|
|
||||||
Assert.Equal("secret_value", apiKey.Secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that TryParseAuthorizationHeader returns false for malformed tokens.</summary>
|
|
||||||
/// <param name="authorizationHeader">Malformed authorization header value.</param>
|
|
||||||
[Theory]
|
|
||||||
[InlineData(null)]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData("mxgw_operator01_secret")]
|
|
||||||
[InlineData("Bearer not-a-gateway-key")]
|
|
||||||
[InlineData("Bearer mxgw__secret")]
|
|
||||||
[InlineData("Bearer mxgw_operator01_")]
|
|
||||||
public void TryParseAuthorizationHeader_MalformedToken_ReturnsFalse(string? authorizationHeader)
|
|
||||||
{
|
|
||||||
ApiKeyParser parser = new();
|
|
||||||
|
|
||||||
bool parsed = parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? apiKey);
|
|
||||||
|
|
||||||
Assert.False(parsed);
|
|
||||||
Assert.Null(apiKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed class ApiKeySecretHasherTests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies identical pepper and secret produce identical hashes.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void HashSecret_SamePepperAndSecret_ReturnsSameHash()
|
|
||||||
{
|
|
||||||
ApiKeySecretHasher hasher = CreateHasher("pepper-one");
|
|
||||||
|
|
||||||
byte[] firstHash = hasher.HashSecret("raw-secret");
|
|
||||||
byte[] secondHash = hasher.HashSecret("raw-secret");
|
|
||||||
|
|
||||||
Assert.Equal(firstHash, secondHash);
|
|
||||||
Assert.NotEqual("raw-secret"u8.ToArray(), firstHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies different pepper values produce different hashes.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void HashSecret_DifferentPepper_ReturnsDifferentHash()
|
|
||||||
{
|
|
||||||
byte[] firstHash = CreateHasher("pepper-one").HashSecret("raw-secret");
|
|
||||||
byte[] secondHash = CreateHasher("pepper-two").HashSecret("raw-secret");
|
|
||||||
|
|
||||||
Assert.NotEqual(firstHash, secondHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies missing pepper throws an exception.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void HashSecret_MissingPepper_Throws()
|
|
||||||
{
|
|
||||||
ApiKeySecretHasher hasher = CreateHasher(pepper: null);
|
|
||||||
|
|
||||||
Assert.Throws<ApiKeyPepperUnavailableException>(() => hasher.HashSecret("raw-secret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ApiKeySecretHasher CreateHasher(string? pepper)
|
|
||||||
{
|
|
||||||
Dictionary<string, string?> values = [];
|
|
||||||
|
|
||||||
if (pepper is not null)
|
|
||||||
{
|
|
||||||
values["TestPepper"] = pepper;
|
|
||||||
}
|
|
||||||
|
|
||||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
|
||||||
.AddInMemoryCollection(values)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
GatewayOptions options = new()
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationOptions
|
|
||||||
{
|
|
||||||
PepperSecretName = "TestPepper"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new ApiKeySecretHasher(configuration, Options.Create(options));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Configuration;
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parity tests for the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> verifier as the gateway relies on it:
|
||||||
|
/// the <c>mxgw</c> token format, peppered HMAC-SHA256 secret hashing, constant-time comparison,
|
||||||
|
/// fail-closed discrimination (missing/unknown/revoked/wrong-secret/missing-pepper), and that the
|
||||||
|
/// raw secret never leaks into the result. The expected hash is computed here independently to keep
|
||||||
|
/// the test honest against the library's internal hasher.
|
||||||
|
/// </summary>
|
||||||
public sealed class ApiKeyVerifierTests
|
public sealed class ApiKeyVerifierTests
|
||||||
{
|
{
|
||||||
|
private static readonly ApiKeyOptions Options = new() { TokenPrefix = "mxgw" };
|
||||||
|
|
||||||
/// <summary>Verifies that VerifyAsync returns identity and scopes for a valid key.</summary>
|
/// <summary>Verifies that VerifyAsync returns identity and scopes for a valid key.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
|
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
|
||||||
{
|
{
|
||||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
|
||||||
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
|
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
|
||||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
|
||||||
|
|
||||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
ApiKeyVerification result = await verifier.VerifyAsync(
|
||||||
"Bearer mxgw_operator01_correct-secret",
|
"Bearer mxgw_operator01_correct-secret",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -33,11 +41,10 @@ public sealed class ApiKeyVerifierTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
|
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
|
||||||
{
|
{
|
||||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
|
||||||
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
|
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
|
||||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
|
||||||
|
|
||||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
ApiKeyVerification result = await verifier.VerifyAsync(
|
||||||
"Bearer mxgw_operator01_correct-secret",
|
"Bearer mxgw_operator01_correct-secret",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -46,58 +53,51 @@ public sealed class ApiKeyVerifierTests
|
|||||||
Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal);
|
Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that VerifyAsync fails with unauthenticated status for a malformed key.</summary>
|
/// <summary>Verifies that VerifyAsync fails as missing/malformed for a malformed key.</summary>
|
||||||
/// <param name="authorizationHeader">Authorization header value to test.</param>
|
/// <param name="authorizationHeader">Authorization header value to test.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null)]
|
[InlineData("")]
|
||||||
[InlineData("Bearer mxgw_operator01")]
|
[InlineData("Bearer mxgw_operator01")]
|
||||||
[InlineData("Bearer wrong")]
|
[InlineData("Bearer wrong")]
|
||||||
public async Task VerifyAsync_MalformedKey_FailsUnauthenticated(string? authorizationHeader)
|
public async Task VerifyAsync_MalformedKey_FailsMissingOrMalformed(string authorizationHeader)
|
||||||
{
|
{
|
||||||
ApiKeyVerifier verifier = new(
|
ApiKeyVerifier verifier = new(Options, new FakeApiKeyStore(storedKey: null), new FakePepperProvider("pepper"));
|
||||||
new ApiKeyParser(),
|
|
||||||
CreateHasher("pepper"),
|
|
||||||
new FakeApiKeyStore(storedKey: null));
|
|
||||||
|
|
||||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
ApiKeyVerification result = await verifier.VerifyAsync(
|
||||||
authorizationHeader,
|
authorizationHeader,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure);
|
Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that VerifyAsync fails for an unknown key.</summary>
|
/// <summary>Verifies that VerifyAsync fails for an unknown key.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerifyAsync_UnknownKey_Fails()
|
public async Task VerifyAsync_UnknownKey_Fails()
|
||||||
{
|
{
|
||||||
ApiKeyVerifier verifier = new(
|
ApiKeyVerifier verifier = new(Options, new FakeApiKeyStore(storedKey: null), new FakePepperProvider("pepper"));
|
||||||
new ApiKeyParser(),
|
|
||||||
CreateHasher("pepper"),
|
|
||||||
new FakeApiKeyStore(storedKey: null));
|
|
||||||
|
|
||||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
ApiKeyVerification result = await verifier.VerifyAsync(
|
||||||
"Bearer mxgw_missing_secret",
|
"Bearer mxgw_missing_secret",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure);
|
Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that VerifyAsync fails for a wrong secret.</summary>
|
/// <summary>Verifies that VerifyAsync fails for a wrong secret (constant-time compare rejects it).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerifyAsync_WrongSecret_Fails()
|
public async Task VerifyAsync_WrongSecret_Fails()
|
||||||
{
|
{
|
||||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
|
||||||
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
|
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
|
||||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
|
||||||
|
|
||||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
ApiKeyVerification result = await verifier.VerifyAsync(
|
||||||
"Bearer mxgw_operator01_wrong-secret",
|
"Bearer mxgw_operator01_wrong-secret",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, result.Failure);
|
Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
|
||||||
Assert.False(store.MarkedUsed);
|
Assert.False(store.MarkedUsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,74 +105,62 @@ public sealed class ApiKeyVerifierTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerifyAsync_RevokedKey_Fails()
|
public async Task VerifyAsync_RevokedKey_Fails()
|
||||||
{
|
{
|
||||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
FakeApiKeyStore store = new(CreateRecord("pepper", DateTimeOffset.UtcNow));
|
||||||
FakeApiKeyStore store = new(CreateRecord(hasher, DateTimeOffset.UtcNow));
|
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
|
||||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
|
||||||
|
|
||||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
ApiKeyVerification result = await verifier.VerifyAsync(
|
||||||
"Bearer mxgw_operator01_correct-secret",
|
"Bearer mxgw_operator01_correct-secret",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, result.Failure);
|
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
|
||||||
Assert.False(store.MarkedUsed);
|
Assert.False(store.MarkedUsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that VerifyAsync fails when the pepper is missing.</summary>
|
/// <summary>Verifies that VerifyAsync fails closed when the pepper is missing.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task VerifyAsync_MissingPepper_Fails()
|
public async Task VerifyAsync_MissingPepper_Fails()
|
||||||
{
|
{
|
||||||
FakeApiKeyStore store = new(CreateRecord(CreateHasher("pepper"), revokedUtc: null));
|
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
|
||||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), CreateHasher(pepper: null), store);
|
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider(pepper: null));
|
||||||
|
|
||||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
ApiKeyVerification result = await verifier.VerifyAsync(
|
||||||
"Bearer mxgw_operator01_correct-secret",
|
"Bearer mxgw_operator01_correct-secret",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Equal(ApiKeyVerificationFailure.PepperUnavailable, result.Failure);
|
Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApiKeyRecord CreateRecord(ApiKeySecretHasher hasher, DateTimeOffset? revokedUtc)
|
/// <summary>Computes HMAC-SHA256(pepper, secret) — the documented peppered-hash format.</summary>
|
||||||
|
private static byte[] PepperedHash(string secret, string pepper)
|
||||||
|
{
|
||||||
|
using HMACSHA256 hmac = new(Encoding.UTF8.GetBytes(pepper));
|
||||||
|
return hmac.ComputeHash(Encoding.UTF8.GetBytes(secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiKeyRecord CreateRecord(string pepper, DateTimeOffset? revokedUtc)
|
||||||
{
|
{
|
||||||
return new ApiKeyRecord(
|
return new ApiKeyRecord(
|
||||||
KeyId: "operator01",
|
KeyId: "operator01",
|
||||||
KeyPrefix: "mxgw_operator01",
|
KeyPrefix: "mxgw",
|
||||||
SecretHash: hasher.HashSecret("correct-secret"),
|
SecretHash: PepperedHash("correct-secret", pepper),
|
||||||
DisplayName: "Operator Key",
|
DisplayName: "Operator Key",
|
||||||
Scopes: new HashSet<string>(StringComparer.Ordinal)
|
Scopes: new HashSet<string>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
"session:open",
|
"session:open",
|
||||||
"events:read"
|
"events:read"
|
||||||
},
|
},
|
||||||
Constraints: ApiKeyConstraints.Empty,
|
ConstraintsJson: null,
|
||||||
CreatedUtc: DateTimeOffset.UtcNow,
|
CreatedUtc: DateTimeOffset.UtcNow,
|
||||||
LastUsedUtc: null,
|
LastUsedUtc: null,
|
||||||
RevokedUtc: revokedUtc);
|
RevokedUtc: revokedUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApiKeySecretHasher CreateHasher(string? pepper)
|
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
|
||||||
{
|
{
|
||||||
Dictionary<string, string?> values = [];
|
/// <summary>Returns the configured pepper (or null to simulate an unavailable pepper).</summary>
|
||||||
|
public string? GetPepper() => pepper;
|
||||||
if (pepper is not null)
|
|
||||||
{
|
|
||||||
values["TestPepper"] = pepper;
|
|
||||||
}
|
|
||||||
|
|
||||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
|
||||||
.AddInMemoryCollection(values)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
GatewayOptions options = new()
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationOptions
|
|
||||||
{
|
|
||||||
PepperSecretName = "TestPepper"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new ApiKeySecretHasher(configuration, Options.Create(options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fake in-memory API key store for testing.</summary>
|
/// <summary>Fake in-memory API key store for testing.</summary>
|
||||||
@@ -181,18 +169,14 @@ public sealed class ApiKeyVerifierTests
|
|||||||
/// <summary>Gets whether the key was marked as used.</summary>
|
/// <summary>Gets whether the key was marked as used.</summary>
|
||||||
public bool MarkedUsed { get; private set; }
|
public bool MarkedUsed { get; private set; }
|
||||||
|
|
||||||
/// <summary>Finds an API key record by its ID.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="keyId">Identifier of the API key.</param>
|
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
|
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Finds an active (non-revoked) API key record by its ID.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="keyId">Identifier of the API key.</param>
|
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
return Task.FromResult(
|
return Task.FromResult(
|
||||||
storedKey?.KeyId == keyId && storedKey.RevokedUtc is null
|
storedKey?.KeyId == keyId && storedKey.RevokedUtc is null
|
||||||
@@ -200,11 +184,8 @@ public sealed class ApiKeyVerifierTests
|
|||||||
: null);
|
: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Marks an API key as used at the specified time.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="keyId">Identifier of the API key.</param>
|
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||||
/// <param name="usedUtc">Timestamp when the key was used.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
MarkedUsed = storedKey?.KeyId == keyId;
|
MarkedUsed = storedKey?.KeyId == keyId;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
using ZB.MOM.WW.MxGateway.Server;
|
using ZB.MOM.WW.MxGateway.Server;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
@@ -9,13 +11,17 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|||||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for <see cref="SqliteAuthStore"/>.
|
/// Parity tests for the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> SQLite store as wired by the gateway.
|
||||||
|
/// The gateway is the donor this store was extracted from; these tests pin that existing deployed
|
||||||
|
/// <c>gateway-auth.db</c> databases (schema version 2, same tables/columns/scopes encoding) remain
|
||||||
|
/// readable and that migration is idempotent and refuses a newer on-disk schema.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SqliteAuthStoreTests : IDisposable
|
public sealed class SqliteAuthStoreTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that MigrateAsync initializes the database schema.
|
/// Verifies that MigrateAsync initializes the database schema at the donor's version (2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
|
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
|
||||||
@@ -23,7 +29,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
string databasePath = CreateTempDatabasePath();
|
string databasePath = CreateTempDatabasePath();
|
||||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||||
|
|
||||||
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
|
SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();
|
||||||
|
|
||||||
await migrator.MigrateAsync(CancellationToken.None);
|
await migrator.MigrateAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -42,7 +48,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
await CreateVersionZeroDatabaseAsync(databasePath);
|
await CreateVersionZeroDatabaseAsync(databasePath);
|
||||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||||
|
|
||||||
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
|
SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();
|
||||||
|
|
||||||
await migrator.MigrateAsync(CancellationToken.None);
|
await migrator.MigrateAsync(CancellationToken.None);
|
||||||
await migrator.MigrateAsync(CancellationToken.None);
|
await migrator.MigrateAsync(CancellationToken.None);
|
||||||
@@ -74,14 +80,15 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that FindActiveByKeyIdAsync returns an active key.
|
/// Verifies that FindActiveByKeyIdAsync returns an active key, reading a row whose columns match
|
||||||
|
/// the donor schema (peppered secret_hash BLOB, ordinal-sorted scopes JSON).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
|
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
|
||||||
{
|
{
|
||||||
string databasePath = CreateTempDatabasePath();
|
string databasePath = CreateTempDatabasePath();
|
||||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||||
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
||||||
await InsertApiKeyAsync(databasePath, revokedUtc: null);
|
await InsertApiKeyAsync(databasePath, revokedUtc: null);
|
||||||
|
|
||||||
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
||||||
@@ -104,7 +111,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
{
|
{
|
||||||
string databasePath = CreateTempDatabasePath();
|
string databasePath = CreateTempDatabasePath();
|
||||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||||
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
||||||
await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);
|
await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
||||||
@@ -127,7 +134,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
{
|
{
|
||||||
string databasePath = CreateTempDatabasePath();
|
string databasePath = CreateTempDatabasePath();
|
||||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||||
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
||||||
|
|
||||||
IApiKeyAuditStore auditStore = services.GetRequiredService<IApiKeyAuditStore>();
|
IApiKeyAuditStore auditStore = services.GetRequiredService<IApiKeyAuditStore>();
|
||||||
|
|
||||||
@@ -136,14 +143,15 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
KeyId: "test-key",
|
KeyId: "test-key",
|
||||||
EventType: "lookup",
|
EventType: "lookup",
|
||||||
RemoteAddress: "127.0.0.1",
|
RemoteAddress: "127.0.0.1",
|
||||||
|
CreatedUtc: DateTimeOffset.UtcNow,
|
||||||
Details: "matched active key"),
|
Details: "matched active key"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
IReadOnlyList<ApiKeyAuditRecord> records = await auditStore.ListRecentAsync(
|
IReadOnlyList<ApiKeyAuditEntry> records = await auditStore.ListRecentAsync(
|
||||||
10,
|
10,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
ApiKeyAuditRecord record = Assert.Single(records);
|
ApiKeyAuditEntry record = Assert.Single(records);
|
||||||
Assert.Equal("test-key", record.KeyId);
|
Assert.Equal("test-key", record.KeyId);
|
||||||
Assert.Equal("lookup", record.EventType);
|
Assert.Equal("lookup", record.EventType);
|
||||||
Assert.Equal("127.0.0.1", record.RemoteAddress);
|
Assert.Equal("127.0.0.1", record.RemoteAddress);
|
||||||
@@ -188,8 +196,8 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
|
|
||||||
ServiceCollection services = new();
|
ServiceCollection services = new();
|
||||||
services.AddSingleton<IConfiguration>(configuration);
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
services.AddGatewayConfiguration();
|
services.AddGatewayConfiguration(configuration);
|
||||||
services.AddSqliteAuthStore();
|
services.AddSqliteAuthStore(configuration);
|
||||||
|
|
||||||
return services.BuildServiceProvider(validateScopes: true);
|
return services.BuildServiceProvider(validateScopes: true);
|
||||||
}
|
}
|
||||||
@@ -288,7 +296,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
command.Parameters.AddWithValue("$display_name", "Test Key");
|
command.Parameters.AddWithValue("$display_name", "Test Key");
|
||||||
command.Parameters.AddWithValue(
|
command.Parameters.AddWithValue(
|
||||||
"$scopes",
|
"$scopes",
|
||||||
ApiKeyScopeSerializer.Serialize(new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }));
|
ScopeSerializer.Serialize(new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }));
|
||||||
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
|
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||||
command.Parameters.AddWithValue("$revoked_utc", revokedUtc?.ToString("O") ?? (object)DBNull.Value);
|
command.Parameters.AddWithValue("$revoked_utc", revokedUtc?.ToString("O") ?? (object)DBNull.Value);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -6,6 +7,10 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
// ConstraintEnforcer enforces against the gateway's constraint-bearing identity; the shared library
|
||||||
|
// also defines an ApiKeyIdentity, so disambiguate to the gateway type.
|
||||||
|
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
||||||
|
|
||||||
public sealed class ConstraintEnforcerTests
|
public sealed class ConstraintEnforcerTests
|
||||||
@@ -33,7 +38,7 @@ public sealed class ConstraintEnforcerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
|
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
|
||||||
{
|
{
|
||||||
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore);
|
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
|
||||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||||
{
|
{
|
||||||
WriteSubtrees = ["Area1/*"],
|
WriteSubtrees = ["Area1/*"],
|
||||||
@@ -66,10 +71,35 @@ public sealed class ConstraintEnforcerTests
|
|||||||
|
|
||||||
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None);
|
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None);
|
||||||
|
|
||||||
ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries);
|
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
|
||||||
Assert.Equal("operator01", entry.KeyId);
|
Assert.Equal("operator01", auditEvent.Actor);
|
||||||
Assert.Equal("constraint-denied", entry.EventType);
|
Assert.Equal("constraint-denied", auditEvent.Action);
|
||||||
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
|
Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome);
|
||||||
|
Assert.Equal("ApiKey", auditEvent.Category);
|
||||||
|
// Target is the structured "<commandKind>:<target>" form.
|
||||||
|
Assert.Equal("Write:42", auditEvent.Target);
|
||||||
|
Assert.NotNull(auditEvent.DetailsJson);
|
||||||
|
Assert.Contains("max_write_classification", auditEvent.DetailsJson, StringComparison.Ordinal);
|
||||||
|
Assert.Null(auditEvent.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A denial with no identity records the canonical "anonymous" actor.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordDenialAsync_WithoutIdentity_UsesAnonymousActor()
|
||||||
|
{
|
||||||
|
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
|
||||||
|
|
||||||
|
await enforcer.RecordDenialAsync(
|
||||||
|
identity: null,
|
||||||
|
"Read",
|
||||||
|
"Secret.Tag",
|
||||||
|
new ConstraintFailure("read_scope", "Tag is outside the API key read scope."),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
|
||||||
|
Assert.Equal("anonymous", auditEvent.Actor);
|
||||||
|
Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome);
|
||||||
|
Assert.Equal("Read:Secret.Tag", auditEvent.Target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
|
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
|
||||||
@@ -129,10 +159,10 @@ public sealed class ConstraintEnforcerTests
|
|||||||
Assert.Equal("read_historized_only", failure.ConstraintName);
|
Assert.Equal("read_historized_only", failure.ConstraintName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore)
|
private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
|
||||||
{
|
{
|
||||||
auditStore = new FakeAuditStore();
|
auditWriter = new FakeAuditWriter();
|
||||||
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore);
|
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditWriter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints)
|
private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints)
|
||||||
@@ -237,22 +267,16 @@ public sealed class ConstraintEnforcerTests
|
|||||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeAuditStore : IApiKeyAuditStore
|
private sealed class FakeAuditWriter : IAuditWriter
|
||||||
{
|
{
|
||||||
/// <summary>Gets the recorded audit entries.</summary>
|
/// <summary>Gets the recorded canonical audit events.</summary>
|
||||||
public List<ApiKeyAuditEntry> Entries { get; } = [];
|
public List<AuditEvent> Events { get; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Entries.Add(entry);
|
Events.Add(auditEvent);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-16
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
@@ -12,6 +13,11 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||||
|
|
||||||
|
// The handler exposes the gateway's constraint-bearing identity; alias the shared library identity
|
||||||
|
// (returned by the verifier) so the two can be referenced unambiguously.
|
||||||
|
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
|
||||||
|
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
||||||
|
|
||||||
public sealed class GatewayGrpcAuthorizationInterceptorTests
|
public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||||
@@ -21,8 +27,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated()
|
public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated()
|
||||||
{
|
{
|
||||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||||
new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(
|
new FakeApiKeyVerifier(Failure(ApiKeyFailure.MissingOrMalformed)),
|
||||||
ApiKeyVerificationFailure.MissingOrMalformedCredentials)),
|
|
||||||
new GatewayRequestIdentityAccessor());
|
new GatewayRequestIdentityAccessor());
|
||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
@@ -40,7 +45,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus()
|
public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus()
|
||||||
{
|
{
|
||||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||||
new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)),
|
new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)),
|
||||||
new GatewayRequestIdentityAccessor());
|
new GatewayRequestIdentityAccessor());
|
||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
@@ -146,8 +151,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification()
|
public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification()
|
||||||
{
|
{
|
||||||
GatewayRequestIdentityAccessor identityAccessor = new();
|
GatewayRequestIdentityAccessor identityAccessor = new();
|
||||||
FakeApiKeyVerifier verifier = new(ApiKeyVerificationResult.Fail(
|
FakeApiKeyVerifier verifier = new(Failure(ApiKeyFailure.MissingOrMalformed));
|
||||||
ApiKeyVerificationFailure.MissingOrMalformedCredentials));
|
|
||||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||||
verifier,
|
verifier,
|
||||||
identityAccessor,
|
identityAccessor,
|
||||||
@@ -374,13 +378,21 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes)
|
private static ApiKeyVerification SuccessWithScopes(params string[] scopes)
|
||||||
{
|
{
|
||||||
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
return new ApiKeyVerification(
|
||||||
KeyId: "operator01",
|
Succeeded: true,
|
||||||
KeyPrefix: "mxgw_operator01",
|
Identity: new LibApiKeyIdentity(
|
||||||
DisplayName: "Operator Key",
|
KeyId: "operator01",
|
||||||
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
|
DisplayName: "Operator Key",
|
||||||
|
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal),
|
||||||
|
Constraints: null),
|
||||||
|
Failure: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiKeyVerification Failure(ApiKeyFailure failure)
|
||||||
|
{
|
||||||
|
return new ApiKeyVerification(Succeeded: false, Identity: null, Failure: failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TestServerCallContext ContextWithAuthorization(string authorizationHeader)
|
private static TestServerCallContext ContextWithAuthorization(string authorizationHeader)
|
||||||
@@ -495,7 +507,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||||
{
|
{
|
||||||
/// <summary>Gets whether the verifier was called.</summary>
|
/// <summary>Gets whether the verifier was called.</summary>
|
||||||
public bool WasCalled { get; private set; }
|
public bool WasCalled { get; private set; }
|
||||||
@@ -505,11 +517,11 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
|
|
||||||
/// <summary>Verifies the authorization header against stored result.</summary>
|
/// <summary>Verifies the authorization header against stored result.</summary>
|
||||||
/// <param name="authorizationHeader">The authorization header to verify.</param>
|
/// <param name="authorizationHeader">The authorization header to verify.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>Configured verification result.</returns>
|
/// <returns>Configured verification result.</returns>
|
||||||
public Task<ApiKeyVerificationResult> VerifyAsync(
|
public Task<ApiKeyVerification> VerifyAsync(
|
||||||
string? authorizationHeader,
|
string authorizationHeader,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
WasCalled = true;
|
WasCalled = true;
|
||||||
LastAuthorizationHeader = authorizationHeader;
|
LastAuthorizationHeader = authorizationHeader;
|
||||||
|
|||||||
Reference in New Issue
Block a user