f692f6afe4
`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide data-plane authorization at the three seams that have one, and gives HistoryRead a check it never had. Read, Browse and Subscribe stay ungated — stage 6. The ACL decision REPLACES the server-wide role check rather than chaining with it. Chained, a grant would have to confer the role as well as pass the scope check, and only Cluster-scoped grants confer a role — so every narrow grant would have stayed inert and the flag would have looked inert too. The load-bearing test is an ALLOW: a session carrying no roles at all writing successfully on the strength of its grant. A deny-only test passes against a build where the ACL is never consulted, because the role gate denies too. Found en route: the principal never reached the session. The authenticator passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity derives DisplayName from the presented token — for a key-bearing client that is the static username `apikey`, identical fleet-wide. So the trie's matching key was dropped at the identity boundary, and the audit ClientUserId on a rejected write recorded `apikey` for every consuming system. Principal and ApiKeyId are now carried explicitly. Beyond the stage as sketched: - Revocation reaches an OPEN session. Every decision re-checks the authenticating key against the current snapshot; without it a key revoked by a deployment kept its authority until the client chose to reconnect, which for a machine-to-machine consumer can be indefinitely. - UserAuthorizationState is built per CALL, not per session. The per-session shape exists so an LDAP session can re-resolve membership that changed out of band; nothing can change out of band under API keys, so the generation stamp is the whole invalidation signal and a per-call record picks it up for free. IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial. - The trie is built inside DataPlaneAuthorityState.Apply, under the same generation as the snapshot, rather than by a second wiring at the publish actor. An uninstalled trie denies everything — indistinguishable from working enforcement. Deleting the install turns 13 of the 22 new tests red. - The deploy log reports what the BUILD dropped instead of re-deriving it. The old count agreed with the builder on one of its four drop reasons; a rule with a blank ScopeId, a retired scope kind, or a path not starting at a realm vanished with the log reading zero. Both IApiKeyStore implementations widened to the full grant set. The narrowing was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and became a trap once scope was enforced. Pinned by asserting the roles are unchanged, with the two grants carrying different flags so it cannot pass by accident. Falsified by mutation: enforcement never engaging, the write seam ignoring the ACL, the realm dropped from the scope, the revocation check removed, and the trie never installed each turn the intended tests red and no others. Full solution suite green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
151 lines
6.2 KiB
C#
151 lines
6.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// The key source for a driver-only node (#520 stage 4) — the one with no ConfigDb, which until this
|
|
/// stage denied every UserName token.
|
|
/// </summary>
|
|
public sealed class ArtifactApiKeyStoreTests
|
|
{
|
|
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
/// <summary>
|
|
/// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty.
|
|
/// </summary>
|
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
|
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
|
|
|
[Fact]
|
|
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
|
|
{
|
|
var sut = new ArtifactApiKeyStore(new DataPlaneAuthorityState());
|
|
|
|
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_deployed_key_resolves_with_its_principals_cluster_grants()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply("cluster-a", "rev-1",
|
|
[Key("abc", "scadabridge-dcl")],
|
|
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], NoScopePaths);
|
|
|
|
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
|
|
|
found.ShouldNotBeNull();
|
|
found!.Key.Principal.ShouldBe("scadabridge-dcl");
|
|
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The FULL grant set is returned, narrow scopes included (#520 stage 5). It used to narrow to
|
|
/// <c>Cluster</c> here, which was redundant — <see cref="ApiKeyInterimRoleMapper"/> applies exactly
|
|
/// that filter itself — and became a trap once scope was enforced, because a caller reading
|
|
/// <c>ApiKeyLookup.Grants</c> as "this principal's grants" got a silently truncated set.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Cluster confinement still happens, just not here: <c>ParseDataPlaneAuthority</c> already kept
|
|
/// only this node's cluster's rows, which is where this store differs from
|
|
/// <c>ConfigDbApiKeyStore</c> (that one reads the fleet-wide database and confines it itself; see
|
|
/// <c>ConfigDbApiKeyStoreTests</c>).
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Grants_narrower_than_cluster_are_returned_too()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply("cluster-a", "rev-1",
|
|
[Key("abc", "p")],
|
|
[
|
|
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
|
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
|
], NoScopePaths);
|
|
|
|
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
|
|
|
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Widening the store did not widen the ROLES. The narrow grant reaches the mapper and still
|
|
/// confers nothing, so a rule authored as "WriteOperate on one equipment" cannot become
|
|
/// server-wide write authority on a node with ACL enforcement switched off.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This assertion is the reason the widening is safe, and it lives here rather than only in the
|
|
/// mapper's own tests because the store is what changed: a test of the mapper alone would have
|
|
/// stayed green no matter what the store returned.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Widening_the_store_does_not_widen_the_interim_roles()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply("cluster-a", "rev-1",
|
|
[Key("abc", "p")],
|
|
[Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate)],
|
|
NoScopePaths);
|
|
|
|
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
|
|
|
found!.Grants.ShouldHaveSingleItem();
|
|
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_revoked_key_is_denied_once_the_revoking_artifact_arrives()
|
|
{
|
|
// The whole revocation story for a driver-only node: it takes effect on the next deployment, and
|
|
// this is that deployment landing.
|
|
var state = new DataPlaneAuthorityState();
|
|
var sut = new ArtifactApiKeyStore(state);
|
|
|
|
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopePaths);
|
|
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
|
|
|
|
var revoked = Key("abc", "p");
|
|
revoked.Enabled = false;
|
|
state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths);
|
|
|
|
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Anonymous_grants_resolve_without_a_key()
|
|
{
|
|
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply("cluster-a", "rev-1", [],
|
|
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)],
|
|
NoScopePaths);
|
|
|
|
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
|
|
|
grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.Read);
|
|
}
|
|
|
|
private static ApiKey Key(string id, string principal) => new()
|
|
{
|
|
ApiKeyId = id,
|
|
Name = id,
|
|
SecretHash = "hash",
|
|
Principal = principal,
|
|
Enabled = true,
|
|
CreatedUtc = Now.AddDays(-1),
|
|
};
|
|
|
|
private static NodeAcl Acl(string id, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
|
|
{
|
|
NodeAclId = id,
|
|
ClusterId = "cluster-a",
|
|
Principal = principal,
|
|
ScopeKind = scope,
|
|
PermissionFlags = flags,
|
|
};
|
|
}
|