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:
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine (#520), so a
|
||||
/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a
|
||||
/// <see cref="Principal"/> that <see cref="NodeAcl"/> rows grant permissions to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One principal per key, deliberately.</b> The reason to separate keys from principals is so
|
||||
/// that many keys can share one grant set and rotating a key never touches an ACL row — and that
|
||||
/// holds with a single principal per key, because nothing stops two keys naming the same
|
||||
/// principal. A key belonging to several principals at once (union of grants) buys little and
|
||||
/// costs a join table, so it is not modelled. <c>UserAuthorizationState.Principals</c> is still a
|
||||
/// list, so adding it later needs no change on the ACL side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The secret is never stored.</b> <see cref="SecretHash"/> holds a peppered HMAC-SHA256 of the
|
||||
/// secret half only; the plaintext is shown once at creation and is unrecoverable afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Revocation requires a deploy.</b> Phase 4 leaves driver-only nodes with no ConfigDb, so
|
||||
/// keys reach them inside the deployment artifact exactly as <see cref="NodeAcl"/> rows do.
|
||||
/// Setting <see cref="Enabled"/> to false takes effect on a node only once a new artifact reaches
|
||||
/// it. This is an operational sharp edge, not an oversight — see the #520 design doc.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ApiKey
|
||||
{
|
||||
/// <summary>Gets or sets the database row ID for this key.</summary>
|
||||
public Guid ApiKeyRowId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the public key id — the middle segment of <c>otua_<keyId>_<secret></c>.
|
||||
/// Carried in the clear so verification is an index seek rather than a scan over every stored hash.
|
||||
/// </summary>
|
||||
public required string ApiKeyId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the human-readable label, e.g. the consuming system's name.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base64 peppered HMAC-SHA256 of the key's secret segment. Never the secret.
|
||||
/// </summary>
|
||||
public required string SecretHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the principal this key authenticates as. <see cref="NodeAcl.Principal"/> rows
|
||||
/// naming this value grant it permissions.
|
||||
/// </summary>
|
||||
public required string Principal { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether the key may authenticate. See the revocation caveat on the type.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Gets or sets when the key was created (UTC).</summary>
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets when the key expires (UTC); null never expires.</summary>
|
||||
public DateTime? ExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets optional operator notes.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Optimistic concurrency token, matching the rest of the live-edit config model.</summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
Generated
+1618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddApiKeyTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKey",
|
||||
columns: table => new
|
||||
{
|
||||
ApiKeyRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
ApiKeyId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
SecretHash = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Principal = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ExpiresUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiKey", x => x.ApiKeyRowId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKey_Principal",
|
||||
table: "ApiKey",
|
||||
column: "Principal");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ApiKey_ApiKeyId",
|
||||
table: "ApiKey",
|
||||
column: "ApiKeyId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKey");
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -41,6 +41,64 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.ToTable("DataProtectionKeys", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("ApiKeyRowId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
|
||||
b.Property<string>("ApiKeyId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("ExpiresUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("Principal")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("rowversion");
|
||||
|
||||
b.Property<string>("SecretHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.HasKey("ApiKeyRowId");
|
||||
|
||||
b.HasIndex("ApiKeyId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ApiKey_ApiKeyId");
|
||||
|
||||
b.HasIndex("Principal")
|
||||
.HasDatabaseName("IX_ApiKey_Principal");
|
||||
|
||||
b.ToTable("ApiKey", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
|
||||
{
|
||||
b.Property<string>("NodeId")
|
||||
|
||||
@@ -40,6 +40,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
|
||||
/// <summary>Gets the DbSet of node ACLs.</summary>
|
||||
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||
|
||||
/// <summary>OPC UA data-plane API keys (#520). One principal per key; secret stored as a peppered hash.</summary>
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
/// <summary>Gets the DbSet of configuration audit logs.</summary>
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
/// <summary>Gets the DbSet of external ID reservations.</summary>
|
||||
@@ -84,6 +87,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
ConfigureUnsTagReference(modelBuilder);
|
||||
ConfigurePollGroup(modelBuilder);
|
||||
ConfigureNodeAcl(modelBuilder);
|
||||
ConfigureApiKey(modelBuilder);
|
||||
ConfigureConfigAuditLog(modelBuilder);
|
||||
ConfigureExternalIdReservation(modelBuilder);
|
||||
ConfigureLdapGroupRoleMapping(modelBuilder);
|
||||
@@ -440,6 +444,29 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureApiKey(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ApiKey>(e =>
|
||||
{
|
||||
e.ToTable("ApiKey");
|
||||
e.HasKey(x => x.ApiKeyRowId);
|
||||
e.Property(x => x.ApiKeyRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
// IsRequired() deliberately deviates from the NodeAclId precedent, which is a nullable column
|
||||
// with a filtered unique index. That shape lets two NULL ids coexist; harmless for a config
|
||||
// row, not something to carry into a credentials table where the id is the lookup key.
|
||||
e.Property(x => x.ApiKeyId).HasMaxLength(64).IsRequired();
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.SecretHash).HasMaxLength(128);
|
||||
e.Property(x => x.Principal).HasMaxLength(256);
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
e.Property(x => x.RowVersion).IsRowVersion();
|
||||
|
||||
// The presented key carries ApiKeyId in the clear precisely so verification is this seek.
|
||||
e.HasIndex(x => x.ApiKeyId).IsUnique().HasDatabaseName("UX_ApiKey_ApiKeyId");
|
||||
e.HasIndex(x => x.Principal).HasDatabaseName("IX_ApiKey_Principal");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<NodeAcl>(e =>
|
||||
|
||||
Reference in New Issue
Block a user