Stream A.1-A.2 per docs/v2/implementation/phase-6-2-authorization-runtime.md.
Seed-data migration (A.3) is a separate follow-up once production LDAP group
DNs are finalised; until then CRUD via the Admin UI handles the fleet set up.
Configuration:
- New AdminRole enum {ConfigViewer, ConfigEditor, FleetAdmin} — string-stored.
- New LdapGroupRoleMapping entity with Id (surrogate PK), LdapGroup (512 chars),
Role (AdminRole enum), ClusterId (nullable, FK to ServerCluster), IsSystemWide,
CreatedAtUtc, Notes.
- EF config: UX_LdapGroupRoleMapping_Group_Cluster unique index on
(LdapGroup, ClusterId) + IX_LdapGroupRoleMapping_Group hot-path index on
LdapGroup for sign-in lookups. Cluster FK cascades on cluster delete.
- Migration 20260419_..._AddLdapGroupRoleMapping generated via `dotnet ef`.
Configuration.Services:
- ILdapGroupRoleMappingService — CRUD surface. Declared as control-plane only
per decision #150; the OPC UA data-path evaluator must NOT depend on this
interface (Phase 6.2 compliance check on control/data-plane separation).
GetByGroupsAsync is the hot-path sign-in lookup.
- LdapGroupRoleMappingService (EF Core impl) enforces the write-time invariant
"exactly one of (ClusterId populated, IsSystemWide=true)" and surfaces
InvalidLdapGroupRoleMappingException on violation. Create auto-populates Id
+ CreatedAtUtc when omitted.
Tests (9 new, all pass) in Configuration.Tests:
- Create sets Id + CreatedAtUtc.
- Create rejects empty LdapGroup.
- Create rejects IsSystemWide=true with populated ClusterId.
- Create rejects IsSystemWide=false with null ClusterId.
- GetByGroupsAsync returns matching rows only.
- GetByGroupsAsync with empty input returns empty (no full-table scan).
- ListAllAsync orders by group then cluster.
- Delete removes the target row.
- Delete of unknown id is a no-op.
Microsoft.EntityFrameworkCore.InMemory 10.0.0 added to Configuration.Tests for
the service-level tests (schema-compliance tests still use the live SQL
fixture).
SchemaComplianceTests updated to expect the new LdapGroupRoleMapping table.
Full solution dotnet test: 1051 passing (baseline 906, Phase 6.1 shipped at
1042, Phase 6.2 Stream A adds 9 = 1051). Pre-existing Client.CLI Subscribe
flake unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57 lines
2.5 KiB
C#
57 lines
2.5 KiB
C#
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
/// <summary>
|
|
/// Maps an LDAP group to an <see cref="AdminRole"/> for Admin UI access. Optionally scoped
|
|
/// to one <see cref="ClusterId"/>; when <see cref="IsSystemWide"/> is true, the grant
|
|
/// applies fleet-wide.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Per <c>docs/v2/plan.md</c> decisions #105 and #150 — this entity is <b>control-plane
|
|
/// only</b>. The OPC UA data-path evaluator does not read these rows; it reads
|
|
/// <see cref="NodeAcl"/> joined directly against the session's resolved LDAP group
|
|
/// memberships. Collapsing the two would let a user inherit tag permissions via an
|
|
/// admin-role claim path never intended as a data-path grant.</para>
|
|
///
|
|
/// <para>Uniqueness: <c>(LdapGroup, ClusterId)</c> — the same LDAP group may hold
|
|
/// different roles on different clusters, but only one row per cluster. A system-wide row
|
|
/// (<c>IsSystemWide = true</c>, <c>ClusterId = null</c>) stacks additively with any
|
|
/// cluster-scoped rows for the same group.</para>
|
|
/// </remarks>
|
|
public sealed class LdapGroupRoleMapping
|
|
{
|
|
/// <summary>Surrogate primary key.</summary>
|
|
public Guid Id { get; set; }
|
|
|
|
/// <summary>
|
|
/// LDAP group DN the membership query returns (e.g. <c>cn=fleet-admin,ou=groups,dc=corp,dc=example</c>).
|
|
/// Comparison is case-insensitive per LDAP conventions.
|
|
/// </summary>
|
|
public required string LdapGroup { get; set; }
|
|
|
|
/// <summary>Admin role this group grants.</summary>
|
|
public required AdminRole Role { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cluster the grant applies to; <c>null</c> when <see cref="IsSystemWide"/> is true.
|
|
/// Foreign key to <see cref="ServerCluster.ClusterId"/>.
|
|
/// </summary>
|
|
public string? ClusterId { get; set; }
|
|
|
|
/// <summary>
|
|
/// <c>true</c> = grant applies across every cluster in the fleet; <c>ClusterId</c> must be null.
|
|
/// <c>false</c> = grant is cluster-scoped; <c>ClusterId</c> must be populated.
|
|
/// </summary>
|
|
public required bool IsSystemWide { get; set; }
|
|
|
|
/// <summary>Row creation timestamp (UTC).</summary>
|
|
public DateTime CreatedAtUtc { get; set; }
|
|
|
|
/// <summary>Optional human-readable note (e.g. "added 2026-04-19 for Warsaw fleet admin handoff").</summary>
|
|
public string? Notes { get; set; }
|
|
|
|
/// <summary>Navigation for EF core when the row is cluster-scoped.</summary>
|
|
public ServerCluster? Cluster { get; set; }
|
|
}
|