feat(acl): ApiKey entity + key format, and put Core on the server's graph (#520)

Stage 1b. Still no enforcement wired — this adds the credential model the
identity cutover (stage 2) will read.

ApiKey holds one principal per key, deliberately. The reason to separate keys
from principals is so many keys share a grant set and rotating a key never
touches an ACL row, and that holds with one principal per key since nothing stops
two keys naming the same principal. A key belonging to several principals at once
buys little and costs a join table. UserAuthorizationState.Principals stays a
list, so adding it later needs no ACL-side change.

ApiKeySecret is the security-critical half and is pure, so it is tested on its
own: otua_<keyId>_<secret>, peppered HMAC-SHA256, FixedTimeEquals. Notes why a
plain HMAC rather than an iterated KDF (the secret is 32 bytes of CSPRNG, and the
call runs inline on session activation, which the SDK serializes server-wide
behind its SessionManager event lock), and fails closed on an unset pepper rather
than hashing under an empty key.

Found and fixed a bug in my own first draft: the key id was url-safe base64,
whose alphabet maps '/' to '_' — the very character TryParse splits on. Roughly
one key in five would have had its id truncated at a random position, an
intermittent auth failure reproducing only for SOME keys. The id is hex now, and
the guard is falsified: reverting the alphabet reds both
The_generated_key_id_never_contains_the_separator and, as a bonus signal,
Two_generated_keys_never_collide, because truncated ids collide.

ApiKeyId is NOT NULL, unlike the nullable NodeAclId precedent whose filtered
unique index would admit two NULL ids. Fine for a config row, not for a
credentials lookup key.

OpcUaServer.csproj now references Core. Until now it did not, so
IPermissionEvaluator was literally uncallable from the node manager — the place
enforcement has to happen. No new transitive package surface.

Schema doc updated for both, and its NodeAcl block flagged for pre-existing drift
(it still documents the GenerationId column v3 dropped) without fixing it here.

Core 247, OpcUaServer 364, Configuration 128 — all green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 12:27:28 -04:00
parent 4d376f727f
commit f88eb49bd1
10 changed files with 2125 additions and 7 deletions
+46 -6
View File
@@ -472,13 +472,19 @@ All five are exposed as **OPC UA properties** on the equipment node so external
### `NodeAcl`
> ⚠️ **Pre-existing drift, noted 2026-07-31 and NOT fixed here:** the block below still shows the
> `GenerationId` column and its `ConfigGeneration` FK. v3 made the config tables single live-state and
> dropped generation versioning (see `sp_ComputeGenerationDiff`, which now ignores its generation
> parameters), so the real table has no such column and the index names differ accordingly. Only the
> `LdapGroup` → `Principal` rename below is current.
```sql
CREATE TABLE dbo.NodeAcl (
NodeAclRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
NodeAclId nvarchar(64) NOT NULL, -- stable logical ID across generations
ClusterId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
LdapGroup nvarchar(256) NOT NULL,
Principal nvarchar(256) NOT NULL, -- API-key principal, or the reserved 'anonymous' (#520)
ScopeKind nvarchar(16) NOT NULL CHECK (ScopeKind IN ('Cluster', 'Namespace', 'UnsArea', 'UnsLine', 'Equipment', 'Tag')),
ScopeId nvarchar(64) NULL, -- NULL when ScopeKind='Cluster'; logical ID otherwise
PermissionFlags int NOT NULL, -- bitmask of NodePermissions enum
@@ -487,17 +493,51 @@ CREATE TABLE dbo.NodeAcl (
CREATE INDEX IX_NodeAcl_Generation_Cluster
ON dbo.NodeAcl (GenerationId, ClusterId);
CREATE INDEX IX_NodeAcl_Generation_Group
ON dbo.NodeAcl (GenerationId, LdapGroup);
CREATE INDEX IX_NodeAcl_Generation_Principal
ON dbo.NodeAcl (GenerationId, Principal);
CREATE INDEX IX_NodeAcl_Generation_Scope
ON dbo.NodeAcl (GenerationId, ScopeKind, ScopeId) WHERE ScopeId IS NOT NULL;
CREATE UNIQUE INDEX UX_NodeAcl_Generation_LogicalId
ON dbo.NodeAcl (GenerationId, NodeAclId);
-- Within a generation, a (Group, Scope) pair has at most one row
CREATE UNIQUE INDEX UX_NodeAcl_Generation_GroupScope
ON dbo.NodeAcl (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId);
-- Within a generation, a (Principal, Scope) pair has at most one row
CREATE UNIQUE INDEX UX_NodeAcl_Generation_PrincipalScope
ON dbo.NodeAcl (GenerationId, ClusterId, Principal, ScopeKind, ScopeId);
```
### `ApiKey`
OPC UA data-plane credentials (#520). The data plane is machine-to-machine, so a consuming system
authenticates with a key rather than an LDAP user; the key resolves to a `Principal` that `NodeAcl`
rows grant permissions to. One principal per key — two keys may name the same principal, which is
what lets a key be rotated without touching an ACL row.
```sql
CREATE TABLE dbo.ApiKey (
ApiKeyRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
ApiKeyId nvarchar(64) NOT NULL, -- public id; middle segment of otua_<keyId>_<secret>
Name nvarchar(128) NOT NULL, -- human label, e.g. the consuming system
SecretHash nvarchar(128) NOT NULL, -- base64 peppered HMAC-SHA256; NEVER the secret
Principal nvarchar(256) NOT NULL,
Enabled bit NOT NULL,
CreatedUtc datetime2 NOT NULL,
ExpiresUtc datetime2 NULL,
Notes nvarchar(512) NULL,
RowVersion rowversion NOT NULL
);
-- The presented key carries ApiKeyId in the clear precisely so verification is this seek.
CREATE UNIQUE INDEX UX_ApiKey_ApiKeyId ON dbo.ApiKey (ApiKeyId);
CREATE INDEX IX_ApiKey_Principal ON dbo.ApiKey (Principal);
```
`ApiKeyId` is `NOT NULL` here, deliberately unlike the nullable `NodeAclId` precedent: that shape
permits two NULL ids under a filtered unique index, which is acceptable for a config row and not for
a credentials lookup key.
⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
`Enabled` takes effect on a node only once a new artifact reaches it.
`NodeAcl` is **generation-versioned** (decision #130). ACL changes go through draft → diff → publish → rollback like every other content table. Cross-generation invariant: `NodeAclId` once published with `(LdapGroup, ScopeKind, ScopeId)` cannot have any of those columns change in a future generation; rename an LDAP group by disabling the old grant and creating a new one.
`PermissionFlags` is a bitmask of the `NodePermissions` enum defined in `acl-design.md` (Browse, Read, Subscribe, HistoryRead, WriteOperate, WriteTune, WriteConfigure, AlarmRead, AlarmAcknowledge, AlarmConfirm, AlarmShelve, MethodCall). Common bundles (`ReadOnly`, `Operator`, `Engineer`, `Admin`) expand to specific flag combinations at evaluation time.