|
|
|
@@ -61,8 +61,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
/// Akka.NET conventions, coordinator-style actors use a Resume-based strategy so a faulted
|
|
|
|
|
/// child preserves its state rather than restarting. <see cref="ManagementActor"/> spawns
|
|
|
|
|
/// no children today, but declaring the strategy explicitly matches the convention and
|
|
|
|
|
/// makes the contract correct ahead of any future worker actors (finding
|
|
|
|
|
/// ManagementService-005).
|
|
|
|
|
/// makes the contract correct ahead of any future worker actors.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <returns>A one-for-one Resume strategy with no retry limit.</returns>
|
|
|
|
|
public static SupervisorStrategy CreateSupervisorStrategy() =>
|
|
|
|
@@ -77,7 +76,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Serializer settings for command results. <see cref="ReferenceHandler.IgnoreCycles"/>
|
|
|
|
|
/// keeps EF-backed entity graphs with bidirectional navigation properties from throwing;
|
|
|
|
|
/// camelCase matches what the CLI / HTTP layer expect (finding ManagementService-007).
|
|
|
|
|
/// camelCase matches what the CLI / HTTP layer expect.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly JsonSerializerOptions ResultSerializerOptions = new()
|
|
|
|
|
{
|
|
|
|
@@ -151,7 +150,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
// unanticipated fault whose raw .Message can disclose internal detail
|
|
|
|
|
// (server/database names, constraint names, stack context) — return a
|
|
|
|
|
// generic message and let the operator correlate via the server log
|
|
|
|
|
// using the correlation ID (finding ManagementService-016).
|
|
|
|
|
// using the correlation ID.
|
|
|
|
|
var clientMessage = cause is ManagementCommandException
|
|
|
|
|
? cause.Message
|
|
|
|
|
: $"An internal error occurred (CorrelationId={correlationId}).";
|
|
|
|
@@ -173,9 +172,9 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
// (which requires OperationalAuditRoles). New audit consumers
|
|
|
|
|
// should use the REST endpoint; this command is retained for
|
|
|
|
|
// backward compatibility with the CentralUI Configuration Audit
|
|
|
|
|
// Log page (Management-018).
|
|
|
|
|
// Log page.
|
|
|
|
|
or QueryAuditLogCommand
|
|
|
|
|
// MgmtSvc-021: SMTP/SMS provider-config updates rotate stored secrets
|
|
|
|
|
// SMTP/SMS provider-config updates rotate stored secrets
|
|
|
|
|
// (SMTP Credentials, Twilio AuthToken). Both the /notifications/smtp and
|
|
|
|
|
// /notifications/sms Central UI pages enforce RequireAdmin, so the actor
|
|
|
|
|
// gate must match — otherwise a Designer blocked in the UI could still
|
|
|
|
@@ -229,7 +228,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
|
|
|
|
or DebugSnapshotCommand => Roles.Deployer,
|
|
|
|
|
|
|
|
|
|
// Two-person secured write (M7 / T14b). Submit is an Operator action;
|
|
|
|
|
// Two-person secured write. Submit is an Operator action;
|
|
|
|
|
// approve/reject are Verifier actions. Separation of duties (a write may
|
|
|
|
|
// not be verified by its submitter) is enforced inside the handler — the
|
|
|
|
|
// role gate only ensures the caller holds the right coarse role.
|
|
|
|
@@ -349,7 +348,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user.Username),
|
|
|
|
|
DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user.Username),
|
|
|
|
|
|
|
|
|
|
// Schema Library (M9-T32c)
|
|
|
|
|
// Schema Library
|
|
|
|
|
ListSharedSchemasCommand => await HandleListSharedSchemas(sp),
|
|
|
|
|
GetSharedSchemaCommand cmd => await HandleGetSharedSchema(sp, cmd),
|
|
|
|
|
CreateSharedSchemaCommand cmd => await HandleCreateSharedSchema(sp, cmd, user.Username),
|
|
|
|
@@ -403,15 +402,15 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
|
|
|
|
|
DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user),
|
|
|
|
|
|
|
|
|
|
// Secured writes (M7 / T14b). Approve executes the write — once a
|
|
|
|
|
// Secured writes. Approve executes the write — once a
|
|
|
|
|
// Verifier wins the compare-and-swap the value is relayed to the site
|
|
|
|
|
// MxGateway connection (Task C3).
|
|
|
|
|
// MxGateway connection.
|
|
|
|
|
SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user),
|
|
|
|
|
ApproveSecuredWriteCommand cmd => await HandleApproveSecuredWrite(sp, cmd, user),
|
|
|
|
|
RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user),
|
|
|
|
|
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd),
|
|
|
|
|
|
|
|
|
|
// Transport (#24) bundle operations
|
|
|
|
|
// Transport bundle operations
|
|
|
|
|
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
|
|
|
|
|
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd),
|
|
|
|
|
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username),
|
|
|
|
@@ -420,8 +419,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
// "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP
|
|
|
|
|
// auth and role resolution itself before sending a single envelope. Leaving a
|
|
|
|
|
// handler would expose role-mapping data to any raw ClusterClient sender with
|
|
|
|
|
// no role requirement; the command now falls through to the default below
|
|
|
|
|
// (finding ManagementService-011).
|
|
|
|
|
// no role requirement; the command now falls through to the default below.
|
|
|
|
|
_ => throw new NotSupportedException($"Unknown command type: {command.GetType().Name}")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
@@ -482,7 +480,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
/// Logs an audit entry after a successful mutation.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Audit-logging contract (finding ManagementService-009). Every mutating operation is
|
|
|
|
|
/// Audit-logging contract. Every mutating operation is
|
|
|
|
|
/// audited exactly once, by whichever layer owns the write:
|
|
|
|
|
/// <list type="bullet">
|
|
|
|
|
/// <item>Handlers that mutate a repository directly (site, area, data-connection,
|
|
|
|
@@ -591,7 +589,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}).ToList()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// M9-T32b: supply the JSON-Schema $ref resolution seam from the shared-schema
|
|
|
|
|
// Supply the JSON-Schema $ref resolution seam from the shared-schema
|
|
|
|
|
// library so a dangling {"$ref":"lib:Name"} in a template script schema is flagged
|
|
|
|
|
// here (design-time validate) consistently with the deploy path. The library is
|
|
|
|
|
// pre-loaded into a name→JSON map (the seam ValidationService consumes is sync).
|
|
|
|
@@ -600,7 +598,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
|
|
|
|
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
|
|
|
|
|
|
|
|
|
|
// #259: also pass shared scripts so a dangling $ref in a SHARED script's schema is
|
|
|
|
|
// Also pass shared scripts so a dangling $ref in a SHARED script's schema is
|
|
|
|
|
// caught at design-time (not only on the deploy path). Mirror the deploy-path mapping
|
|
|
|
|
// in FlatteningPipeline: SharedScript entity → ResolvedScript (name + schema fields).
|
|
|
|
|
var sharedScriptEntities = await repo.GetAllSharedScriptsAsync();
|
|
|
|
@@ -634,7 +632,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Read-only authoring resolve (M9/T26a): returns the effective inherited
|
|
|
|
|
/// Read-only authoring resolve: returns the effective inherited
|
|
|
|
|
/// member set for a template — computed fresh from the full inheritance
|
|
|
|
|
/// chain — plus a staleness summary. Loads every template (children
|
|
|
|
|
/// eager-loaded) so the resolver can walk an arbitrary-depth chain; never
|
|
|
|
@@ -648,7 +646,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Resync inherited members (follow-up #1/#2): reconciles a template's stored
|
|
|
|
|
/// Resync inherited members: reconciles a template's stored
|
|
|
|
|
/// inherited rows (and its derived subtree's) with the resolved effective set —
|
|
|
|
|
/// materializing missing placeholders, re-syncing drifted ones, and removing
|
|
|
|
|
/// orphans — so the editor's editable tabs are complete and the staleness
|
|
|
|
@@ -803,7 +801,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
{
|
|
|
|
|
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
|
|
|
|
|
|
|
|
|
// Multi-override apply is all-or-nothing (finding ManagementService-015).
|
|
|
|
|
// Multi-override apply is all-or-nothing.
|
|
|
|
|
// InstanceService.SetAttributeOverrideAsync commits each override
|
|
|
|
|
// independently, so a mid-batch failure on an invalid attribute would
|
|
|
|
|
// otherwise leave the instance partially mutated. Validate every
|
|
|
|
@@ -962,10 +960,10 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
// Secured-write handlers (M7 / T14b)
|
|
|
|
|
// Secured-write handlers
|
|
|
|
|
//
|
|
|
|
|
// Two-person workflow: an Operator submits a pending write against an
|
|
|
|
|
// MxGateway data connection; a distinct Verifier approves (Task C3) or
|
|
|
|
|
// MxGateway data connection; a distinct Verifier approves or
|
|
|
|
|
// rejects it. The role gate (GetRequiredRole) only verifies the coarse role;
|
|
|
|
|
// the separation-of-duties rule (a write may not be verified by its
|
|
|
|
|
// submitter) is enforced here.
|
|
|
|
@@ -999,7 +997,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
/// identically to every other emit site.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// #206: routes through <see cref="ICentralAuditWriter"/> (rather than calling
|
|
|
|
|
/// Routes through <see cref="ICentralAuditWriter"/> (rather than calling
|
|
|
|
|
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> directly) so the writing
|
|
|
|
|
/// central node's <c>SourceNode</c> (<c>central-a</c>/<c>central-b</c>) is stamped via
|
|
|
|
|
/// the writer's <see cref="INodeIdentityProvider"/> — satisfying the design's
|
|
|
|
@@ -1040,7 +1038,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
verifierUser = row.VerifierUser
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// #206: the central direct-write writer is a singleton that opens its own
|
|
|
|
|
// The central direct-write writer is a singleton that opens its own
|
|
|
|
|
// per-call scope for the (scoped) IAuditLogRepository and stamps SourceNode
|
|
|
|
|
// from the local INodeIdentityProvider — no scope needed here.
|
|
|
|
|
var auditWriter = sp.GetRequiredService<ICentralAuditWriter>();
|
|
|
|
@@ -1090,7 +1088,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
|
|
|
|
|
entity.Id = await repo.AddAsync(entity);
|
|
|
|
|
|
|
|
|
|
// T14b — one append-only audit row per lifecycle event. Emitted AFTER the row is
|
|
|
|
|
// One append-only audit row per lifecycle event. Emitted AFTER the row is
|
|
|
|
|
// persisted (so it carries the store-assigned id); best-effort — see helper.
|
|
|
|
|
await EmitSecuredWriteAuditAsync(
|
|
|
|
|
sp, AuditKind.SecuredWriteSubmit, AuditStatus.Submitted, entity, actor: entity.OperatorUser);
|
|
|
|
@@ -1131,13 +1129,13 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
row.VerifierComment = cmd.Comment;
|
|
|
|
|
row.DecidedAtUtc = decidedAtUtc;
|
|
|
|
|
|
|
|
|
|
// T14b — the approval decision is itself an audited lifecycle event (the
|
|
|
|
|
// The approval decision is itself an audited lifecycle event (the
|
|
|
|
|
// verifier won the CAS). Emitted with the in-flight Submitted status; the
|
|
|
|
|
// Execute row below records the terminal write outcome.
|
|
|
|
|
await EmitSecuredWriteAuditAsync(
|
|
|
|
|
sp, AuditKind.SecuredWriteApprove, AuditStatus.Submitted, row, actor: user.Username);
|
|
|
|
|
|
|
|
|
|
// D2 / T14 TOCTOU guard: re-assert the MxGateway protocol AT EXECUTE, not only at
|
|
|
|
|
// TOCTOU guard: re-assert the MxGateway protocol AT EXECUTE, not only at
|
|
|
|
|
// submit. The connection named on the row may have been reconfigured/recreated as a
|
|
|
|
|
// non-MxGateway (e.g. OPC UA) connection between submit and approval; relaying then
|
|
|
|
|
// would execute the secured write against a non-MxGateway adapter, violating the
|
|
|
|
@@ -1168,7 +1166,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
|
|
|
|
|
// Validate the value type BEFORE attempting the relay. An unknown type can
|
|
|
|
|
// never be decoded/written, so fail the row deterministically rather than
|
|
|
|
|
// leaving it stuck Approved. (Addresses the C2 reviewer's deferred
|
|
|
|
|
// leaving it stuck Approved. (Addresses the reviewer's deferred
|
|
|
|
|
// ValueType-validation note.)
|
|
|
|
|
if (!Enum.TryParse<Commons.Types.Enums.DataType>(row.ValueType, ignoreCase: true, out var dataType))
|
|
|
|
|
{
|
|
|
|
@@ -1182,7 +1180,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
return ToSecuredWriteDto(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// C3 robustness fix: Decode is UNGUARDED in the pre-T14b code — a List-typed
|
|
|
|
|
// Robustness fix: Decode is UNGUARDED without a try/catch — a List-typed
|
|
|
|
|
// value carrying corrupt JSON throws out of the handler and leaves the row
|
|
|
|
|
// stuck Approved. Contain it: fail the row deterministically with the decode
|
|
|
|
|
// error, audit the failure, and return WITHOUT relaying (nothing to write).
|
|
|
|
@@ -1234,7 +1232,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
// UpdateAsync overwrites all columns -> pass the fully-populated entity.
|
|
|
|
|
await repo.UpdateAsync(row);
|
|
|
|
|
|
|
|
|
|
// T14b — terminal execute outcome: Delivered (relay succeeded) maps to canonical
|
|
|
|
|
// Terminal execute outcome: Delivered (relay succeeded) maps to canonical
|
|
|
|
|
// Success, Failed maps to canonical Failure (the error rides in the row detail).
|
|
|
|
|
await EmitSecuredWriteAuditAsync(
|
|
|
|
|
sp,
|
|
|
|
@@ -1271,7 +1269,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
// UpdateAsync overwrites all columns -> pass the fully-populated entity.
|
|
|
|
|
await repo.UpdateAsync(entity);
|
|
|
|
|
|
|
|
|
|
// T14b — reject is a terminal lifecycle event (canonical Discarded outcome).
|
|
|
|
|
// Reject is a terminal lifecycle event (canonical Discarded outcome).
|
|
|
|
|
// Actor = the verifier; the operator is carried in the row's extra detail.
|
|
|
|
|
await EmitSecuredWriteAuditAsync(
|
|
|
|
|
sp, AuditKind.SecuredWriteReject, AuditStatus.Discarded, entity, actor: user.Username);
|
|
|
|
@@ -1458,7 +1456,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Moves a data connection to another site (M9 / T24a). High-risk + data
|
|
|
|
|
/// Moves a data connection to another site. High-risk + data
|
|
|
|
|
/// integrity: every guard runs server-side BEFORE the write, and when in
|
|
|
|
|
/// doubt the move is BLOCKED with a clear error rather than risking an
|
|
|
|
|
/// orphaned binding/reference. No bindings or name references are rewritten.
|
|
|
|
@@ -1754,7 +1752,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// SMS Notifications (S6): build the recipient set for a notification list according
|
|
|
|
|
/// SMS Notifications: build the recipient set for a notification list according
|
|
|
|
|
/// to its delivery channel. Email lists map each <paramref name="recipientEmails"/>
|
|
|
|
|
/// entry to an <see cref="NotificationRecipient.ForEmail"/> recipient; SMS lists map
|
|
|
|
|
/// each <paramref name="recipientPhones"/> entry to an <see cref="NotificationRecipient.ForSms"/>
|
|
|
|
@@ -1781,10 +1779,10 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// MgmtSvc-020: project an SmtpConfiguration to a credential-free shape so the
|
|
|
|
|
/// Project an SmtpConfiguration to a credential-free shape so the
|
|
|
|
|
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
|
|
|
|
|
/// boundary via response payloads or audit afterState. Mirrors the
|
|
|
|
|
/// ApiKey-projection pattern in HandleListApiKeys / CD-012's fix.
|
|
|
|
|
/// ApiKey-projection pattern in HandleListApiKeys.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static object SmtpConfigPublicShape(Commons.Entities.Notifications.SmtpConfiguration c) =>
|
|
|
|
|
new
|
|
|
|
@@ -1806,7 +1804,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
{
|
|
|
|
|
var repo = sp.GetRequiredService<INotificationRepository>();
|
|
|
|
|
var configs = await repo.GetAllSmtpConfigurationsAsync();
|
|
|
|
|
// MgmtSvc-020: project away the Credentials field — read access to this
|
|
|
|
|
// Project away the Credentials field — read access to this
|
|
|
|
|
// list is broader than the Admin-only UpdateSmtpConfig path that owns
|
|
|
|
|
// the secret.
|
|
|
|
|
return configs.Select(SmtpConfigPublicShape).ToList();
|
|
|
|
@@ -1827,7 +1825,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
|
|
|
|
|
await repo.UpdateSmtpConfigurationAsync(config);
|
|
|
|
|
await repo.SaveChangesAsync();
|
|
|
|
|
// MgmtSvc-020: audit the credential-free shape — the *fact of* the change
|
|
|
|
|
// Audit the credential-free shape — the *fact of* the change
|
|
|
|
|
// (and which non-secret fields hold) is observable; the secret value is
|
|
|
|
|
// not persisted to the audit log where OperationalAuditRoles can read it.
|
|
|
|
|
var publicShape = SmtpConfigPublicShape(config);
|
|
|
|
@@ -1836,7 +1834,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// MgmtSvc-020 (SMS): project an SmsConfiguration to a credential-free shape so the
|
|
|
|
|
/// Project an SmsConfiguration to a credential-free shape so the
|
|
|
|
|
/// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
|
|
|
|
|
/// response payloads or audit afterState. Mirrors <see cref="SmtpConfigPublicShape"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
@@ -1858,7 +1856,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
{
|
|
|
|
|
var repo = sp.GetRequiredService<INotificationRepository>();
|
|
|
|
|
var configs = await repo.GetAllSmsConfigurationsAsync();
|
|
|
|
|
// MgmtSvc-020: project away the AuthToken field — read access to this
|
|
|
|
|
// Project away the AuthToken field — read access to this
|
|
|
|
|
// list is broader than the Admin-only UpdateSmsConfig path that owns
|
|
|
|
|
// the secret.
|
|
|
|
|
return configs.Select(SmsConfigPublicShape).ToList();
|
|
|
|
@@ -1877,7 +1875,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
// existing values intact (non-breaking for callers that do not send them,
|
|
|
|
|
// and so the secret AuthToken survives a config edit that does not rotate it).
|
|
|
|
|
if (cmd.ApiBaseUrl is not null) config.ApiBaseUrl = cmd.ApiBaseUrl;
|
|
|
|
|
// MgmtSvc-021: treat an empty/whitespace AuthToken as "omitted", not "clear".
|
|
|
|
|
// Treat an empty/whitespace AuthToken as "omitted", not "clear".
|
|
|
|
|
// A Twilio Auth Token is always required, so an empty value is never a valid
|
|
|
|
|
// rotation — guarding against IsNullOrEmpty (not just null) stops an accidental
|
|
|
|
|
// `--auth-token ""` from silently wiping the stored secret and 401-ing every
|
|
|
|
@@ -1886,7 +1884,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(cmd.AuthToken)) config.AuthToken = cmd.AuthToken;
|
|
|
|
|
await repo.UpdateSmsConfigurationAsync(config);
|
|
|
|
|
await repo.SaveChangesAsync();
|
|
|
|
|
// MgmtSvc-020: audit the credential-free shape — the AuthToken secret is
|
|
|
|
|
// Audit the credential-free shape — the AuthToken secret is
|
|
|
|
|
// not persisted to the audit log where OperationalAuditRoles can read it.
|
|
|
|
|
var publicShape = SmsConfigPublicShape(config);
|
|
|
|
|
await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
|
|
|
|
@@ -1903,7 +1901,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
return await repo.GetAllMappingsAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Security-023 (membership half): an LDAP-group mapping's Role is a free string on the
|
|
|
|
|
// An LDAP-group mapping's Role is a free string on the
|
|
|
|
|
// wire (CLI/API), so reject anything outside the canonical Roles.All set at the single
|
|
|
|
|
// server-side write path. A non-canonical role never functioned (no policy or authz
|
|
|
|
|
// check matches it), so rejecting it removes a silent-misconfiguration footgun rather
|
|
|
|
@@ -1956,7 +1954,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
|
|
|
|
|
private static async Task<object?> HandleListApiKeys(IServiceProvider sp)
|
|
|
|
|
{
|
|
|
|
|
// Inbound-API key re-arch (C2): keys are now managed through the shared
|
|
|
|
|
// Inbound-API key re-arch: keys are now managed through the shared
|
|
|
|
|
// IInboundApiKeyAdmin seam (over the ZB.MOM.WW.Auth.ApiKeys library) rather than
|
|
|
|
|
// the SQL Server IInboundApiRepository. The seam projection is hash-free by
|
|
|
|
|
// construction — only identity, status, and method-scopes are returned; the
|
|
|
|
@@ -1970,7 +1968,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
|
|
|
|
|
private static async Task<object?> HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user)
|
|
|
|
|
{
|
|
|
|
|
// Inbound-API key re-arch (C2): the library mints the key, persists only the
|
|
|
|
|
// Inbound-API key re-arch: the library mints the key, persists only the
|
|
|
|
|
// peppered hash, and assembles the one-time bearer token (sbk_<keyId>_<secret>).
|
|
|
|
|
// The token is shown to the operator only here, in the create response; it cannot
|
|
|
|
|
// be retrieved later. No hash/secret is stored or returned by ScadaBridge.
|
|
|
|
@@ -2019,7 +2017,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
var repo = sp.GetRequiredService<IDeploymentManagerRepository>();
|
|
|
|
|
|
|
|
|
|
// Instance-scoped query: enforce against the target instance's site
|
|
|
|
|
// before reading its deployment history (finding ManagementService-014).
|
|
|
|
|
// before reading its deployment history.
|
|
|
|
|
if (cmd.InstanceId.HasValue)
|
|
|
|
|
{
|
|
|
|
|
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
|
|
|
|
@@ -2037,7 +2035,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
|
|
|
|
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
|
|
|
|
|
|
|
|
|
// ManagementService-023: pre-load all instances ONCE via the repository's
|
|
|
|
|
// Pre-load all instances ONCE via the repository's
|
|
|
|
|
// bulk method and build an InstanceId -> SiteId? lookup, instead of issuing
|
|
|
|
|
// GetInstanceByIdAsync per distinct record.InstanceId (textbook N+1). The
|
|
|
|
|
// unfiltered branch now hits the configuration database exactly twice
|
|
|
|
@@ -2256,7 +2254,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
};
|
|
|
|
|
await repo.AddTemplateNativeAlarmSourceAsync(source);
|
|
|
|
|
await repo.SaveChangesAsync();
|
|
|
|
|
// Propagate the new source to derived descendants (#1/#2). Native-source
|
|
|
|
|
// Propagate the new source to derived descendants. Native-source
|
|
|
|
|
// CRUD lives here (not TemplateService), so call the propagation directly.
|
|
|
|
|
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(cmd.TemplateId, user);
|
|
|
|
|
return source;
|
|
|
|
@@ -2275,7 +2273,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
source.IsLocked = cmd.IsLocked;
|
|
|
|
|
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
|
|
|
|
|
await repo.SaveChangesAsync();
|
|
|
|
|
// Propagate the changed source to derived descendants (#1/#2).
|
|
|
|
|
// Propagate the changed source to derived descendants.
|
|
|
|
|
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
|
|
|
|
return source;
|
|
|
|
|
}
|
|
|
|
@@ -2287,7 +2285,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId);
|
|
|
|
|
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
|
|
|
|
|
await repo.SaveChangesAsync();
|
|
|
|
|
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
|
|
|
|
// Remove the now-orphaned inherited placeholder from derived descendants.
|
|
|
|
|
if (source != null)
|
|
|
|
|
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
|
|
|
|
return cmd.NativeAlarmSourceId;
|
|
|
|
@@ -2392,15 +2390,15 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
// Schema Library handlers (M9-T32c)
|
|
|
|
|
// Schema Library handlers
|
|
|
|
|
//
|
|
|
|
|
// Reusable named JSON-Schema library (SharedSchema + ISharedSchemaRepository,
|
|
|
|
|
// T32a). Repo-backed, mirroring the DatabaseConnection handler idiom: direct
|
|
|
|
|
// Reusable named JSON-Schema library (SharedSchema + ISharedSchemaRepository).
|
|
|
|
|
// Repo-backed, mirroring the DatabaseConnection handler idiom: direct
|
|
|
|
|
// repository writes plus an explicit AuditAsync after each mutation. The unique
|
|
|
|
|
// Name is enforced with a clear duplicate-name error and the SchemaJson is
|
|
|
|
|
// validated as a parseable JSON Schema (reusing InboundApiSchema.Parse) so a
|
|
|
|
|
// malformed library entry never reaches the $ref resolver (T32b) or the
|
|
|
|
|
// schema-driven value-entry forms (T30).
|
|
|
|
|
// malformed library entry never reaches the $ref resolver or the
|
|
|
|
|
// schema-driven value-entry forms.
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
private static async Task<object?> HandleListSharedSchemas(IServiceProvider sp)
|
|
|
|
@@ -2499,7 +2497,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
/// The <c>ParseWithRefs</c> (collecting) variant is used deliberately so a library
|
|
|
|
|
/// entry that itself carries a <c>{"$ref":"lib:Other"}</c> pointer is NOT rejected at
|
|
|
|
|
/// save time merely because the resolver is not consulted here — a dangling/cyclic
|
|
|
|
|
/// ref surfaces later on the deploy-time / runtime resolution path (T32b), exactly
|
|
|
|
|
/// ref surfaces later on the deploy-time / runtime resolution path, exactly
|
|
|
|
|
/// like a forward reference to a not-yet-created entry. Only malformed JSON is a
|
|
|
|
|
/// save-blocking error.
|
|
|
|
|
/// </para>
|
|
|
|
@@ -2681,7 +2679,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
|
|
|
|
|
private static async Task<object?> HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user)
|
|
|
|
|
{
|
|
|
|
|
// Inbound-API key re-arch (C2): enable/disable via the shared seam (no secret change).
|
|
|
|
|
// Inbound-API key re-arch: enable/disable via the shared seam (no secret change).
|
|
|
|
|
var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
|
|
|
|
|
var updated = await admin.SetEnabledAsync(cmd.KeyId, cmd.IsEnabled);
|
|
|
|
|
if (!updated)
|
|
|
|
@@ -2693,7 +2691,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
|
|
|
|
|
private static async Task<object?> HandleSetApiKeyMethods(IServiceProvider sp, SetApiKeyMethodsCommand cmd, string user)
|
|
|
|
|
{
|
|
|
|
|
// Inbound-API key re-arch (C2): replace a key's method-scope set via the shared seam
|
|
|
|
|
// Inbound-API key re-arch: replace a key's method-scope set via the shared seam
|
|
|
|
|
// (no secret change). The library is authoritative for the scope replacement.
|
|
|
|
|
if (cmd.Methods is null || cmd.Methods.Count == 0)
|
|
|
|
|
throw new ManagementCommandException("At least one method must be specified for an API key.");
|
|
|
|
@@ -2800,7 +2798,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================================================
|
|
|
|
|
// Transport (#24) bundle operations
|
|
|
|
|
// Transport bundle operations
|
|
|
|
|
// ========================================================================
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
@@ -2823,11 +2821,11 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync();
|
|
|
|
|
var notificationLists = await notifRepo.GetAllNotificationListsAsync();
|
|
|
|
|
var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync();
|
|
|
|
|
// SMS (S10b): central-only SMS provider configs, mirroring smtpConfigs.
|
|
|
|
|
// SMS: central-only SMS provider configs, mirroring smtpConfigs.
|
|
|
|
|
var smsConfigs = await notifRepo.GetAllSmsConfigurationsAsync();
|
|
|
|
|
// Inbound API keys are not transported between environments (re-arch C4); only methods.
|
|
|
|
|
// Inbound API keys are not transported between environments; only methods.
|
|
|
|
|
var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
|
|
|
|
|
// M8 (B4): site/instance-scoped selection. Sites match on SiteIdentifier
|
|
|
|
|
// Site/instance-scoped selection. Sites match on SiteIdentifier
|
|
|
|
|
// (preferred) or Name; instances match on UniqueName.
|
|
|
|
|
var sites = await siteRepo.GetAllSitesAsync();
|
|
|
|
|
var instances = await templateRepo.GetAllInstancesAsync();
|
|
|
|
@@ -2888,11 +2886,11 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
SmtpConfigurationIds: ResolveIds(smtpConfigs, cmd.SmtpConfigurationNames, s => s.Host, s => s.Id, "SMTP configuration"),
|
|
|
|
|
ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"),
|
|
|
|
|
IncludeDependencies: cmd.IncludeDependencies,
|
|
|
|
|
// M8 (B4): site/instance-scoped selection. Under All=true include every
|
|
|
|
|
// Site/instance-scoped selection. Under All=true include every
|
|
|
|
|
// site + instance, mirroring how All includes every template/etc.
|
|
|
|
|
SiteIds: ResolveSiteIds(),
|
|
|
|
|
InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance"),
|
|
|
|
|
// SMS (S10b): SmsConfiguration is keyed by AccountSid (no Name column);
|
|
|
|
|
// SMS: SmsConfiguration is keyed by AccountSid (no Name column);
|
|
|
|
|
// the bundle preview row shows AccountSid, so the CLI uses AccountSid too.
|
|
|
|
|
SmsConfigurationIds: ResolveIds(smsConfigs, cmd.SmsConfigurationNames, s => s.AccountSid, s => s.Id, "SMS configuration"));
|
|
|
|
|
|
|
|
|
@@ -2936,7 +2934,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
|
|
|
|
|
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
|
|
|
|
|
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
|
|
|
|
|
// M8 (D3): surface the required site/connection mappings so an operator
|
|
|
|
|
// Surface the required site/connection mappings so an operator
|
|
|
|
|
// (CLI or UI) sees which references need resolving before applying.
|
|
|
|
|
return new PreviewBundleResult(
|
|
|
|
|
preview.Items, adds, mods, ids, blocks,
|
|
|
|
@@ -2981,7 +2979,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// M8 (D3): resolve every required site/connection reference into a
|
|
|
|
|
// Resolve every required site/connection reference into a
|
|
|
|
|
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
|
|
|
|
|
// fall back to the preview's auto-match; otherwise (no match) create-new
|
|
|
|
|
// ONLY if the create-missing flag is set, else fail with a clear message.
|
|
|
|
@@ -2992,7 +2990,7 @@ public class ManagementActor : ReceiveActor
|
|
|
|
|
// requires a unique resolution per key. Last-write-wins matches the
|
|
|
|
|
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
|
|
|
|
|
// DataConnection rows the preview item Name is already site-qualified
|
|
|
|
|
// ({site}/{name}, D1-FIX), so keying generically by (EntityType, Name) is
|
|
|
|
|
// ({site}/{name}), so keying generically by (EntityType, Name) is
|
|
|
|
|
// automatically correct — no bare-connection-name special case needed.
|
|
|
|
|
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
|
|
|
|
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
|
|
|
|
@@ -3144,8 +3142,8 @@ public class SiteScopeViolationException : Exception
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Thrown by a command handler to signal a curated, caller-safe failure (finding
|
|
|
|
|
/// ManagementService-016). Its <see cref="Exception.Message"/> is intended to be
|
|
|
|
|
/// Thrown by a command handler to signal a curated, caller-safe failure. Its
|
|
|
|
|
/// <see cref="Exception.Message"/> is intended to be
|
|
|
|
|
/// surfaced verbatim to the HTTP/CLI caller — e.g. a validation result or a
|
|
|
|
|
/// "not found" message. Unanticipated exceptions (database faults, parse errors,
|
|
|
|
|
/// null-reference, etc.) must NOT be this type, so that <c>MapFault</c> can return
|
|
|
|
|