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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user