using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Admin.Services; /// /// Draft-aware write surface over . Replaces direct /// CRUD for Admin UI grant authoring; the raw service stays /// as the read / delete surface. Enforces the invariants listed in Phase 6.2 Stream D.2: /// scope-uniqueness per (LdapGroup, ScopeKind, ScopeId, GenerationId), grant shape /// consistency, and no empty permission masks. /// /// /// Per decision #129 grants are additive — is /// rejected at write time. Explicit Deny is v2.1 and is not representable in the current /// NodeAcl row; attempts to express it (e.g. empty permission set) surface as /// . /// /// Draft scope: writes always target an unpublished (Draft-state) generation id. /// Once a generation publishes, its rows are frozen. /// public sealed class ValidatedNodeAclAuthoringService(OtOpcUaConfigDbContext db) { /// Add a new grant row to the given draft generation. public async Task GrantAsync( long draftGenerationId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId, NodePermissions permissions, string? notes, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup); ValidateGrantShape(scopeKind, scopeId, permissions); await EnsureNoDuplicate(draftGenerationId, clusterId, ldapGroup, scopeKind, scopeId, cancellationToken).ConfigureAwait(false); var row = new NodeAcl { GenerationId = draftGenerationId, NodeAclId = $"acl-{Guid.NewGuid():N}"[..20], ClusterId = clusterId, LdapGroup = ldapGroup, ScopeKind = scopeKind, ScopeId = scopeId, PermissionFlags = permissions, Notes = notes, }; db.NodeAcls.Add(row); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return row; } /// /// Replace an existing grant's permission set in place. Validates the new shape; /// rejects attempts to blank-out to None (that's a Revoke via ). /// public async Task UpdatePermissionsAsync( Guid nodeAclRowId, NodePermissions newPermissions, string? notes, CancellationToken cancellationToken) { if (newPermissions == NodePermissions.None) throw new InvalidNodeAclGrantException( "Permission set cannot be None — revoke the row instead of writing an empty grant."); var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, cancellationToken).ConfigureAwait(false) ?? throw new InvalidNodeAclGrantException($"NodeAcl row {nodeAclRowId} not found."); row.PermissionFlags = newPermissions; if (notes is not null) row.Notes = notes; await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return row; } private static void ValidateGrantShape(NodeAclScopeKind scopeKind, string? scopeId, NodePermissions permissions) { if (permissions == NodePermissions.None) throw new InvalidNodeAclGrantException( "Permission set cannot be None — grants must carry at least one flag (decision #129, additive only)."); if (scopeKind == NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(scopeId)) throw new InvalidNodeAclGrantException( "Cluster-scope grants must have null ScopeId. ScopeId only applies to sub-cluster scopes."); if (scopeKind != NodeAclScopeKind.Cluster && string.IsNullOrEmpty(scopeId)) throw new InvalidNodeAclGrantException( $"ScopeKind={scopeKind} requires a populated ScopeId."); } private async Task EnsureNoDuplicate( long generationId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId, CancellationToken cancellationToken) { var exists = await db.NodeAcls.AsNoTracking() .AnyAsync(a => a.GenerationId == generationId && a.ClusterId == clusterId && a.LdapGroup == ldapGroup && a.ScopeKind == scopeKind && a.ScopeId == scopeId, cancellationToken).ConfigureAwait(false); if (exists) throw new InvalidNodeAclGrantException( $"A grant for (LdapGroup={ldapGroup}, ScopeKind={scopeKind}, ScopeId={scopeId}) already exists in generation {generationId}. " + "Update the existing row's permissions instead of inserting a duplicate."); } } /// Thrown when a grant authoring request violates an invariant. public sealed class InvalidNodeAclGrantException(string message) : Exception(message);