342 lines
20 KiB
C#
342 lines
20 KiB
C#
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Backoffice;
|
|
using Tiku.Application.PlatformAdmin;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.PlatformAdmin;
|
|
|
|
internal sealed partial class PlatformGovernanceService(
|
|
IPlatformControlPlanePersistence platformControlPlanePersistence,
|
|
IJobsOperationsPersistence jobsOperationsPersistence,
|
|
IOperationAuditService auditService) : IPlatformGovernanceService
|
|
{
|
|
public async Task<IReadOnlyCollection<PlatformConfigurationDefinitionItem>> GetConfigurationDefinitionsAsync(
|
|
PlatformApprovalActor actor, CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformConfigurationManage);
|
|
return await platformControlPlanePersistence.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category)
|
|
.ThenBy(item => item.Code)
|
|
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<PlatformConfigurationVersionItem>> GetConfigurationVersionsAsync(
|
|
PlatformApprovalActor actor, string definitionCode, string? environment,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformConfigurationManage);
|
|
var definitionId = await platformControlPlanePersistence.PlatformConfigurationDefinitions.AsNoTracking()
|
|
.Where(item => item.Code == definitionCode).Select(item => (Guid?)item.Id)
|
|
.SingleOrDefaultAsync(cancellationToken)
|
|
?? throw Error("Configuration definition was not found.",
|
|
"platform_configuration_not_found");
|
|
var query = platformControlPlanePersistence.PlatformConfigurationVersions.AsNoTracking()
|
|
.Where(item => item.DefinitionId == definitionId);
|
|
if (!string.IsNullOrWhiteSpace(environment))
|
|
query = query.Where(item => item.Environment == NormalizeEnvironment(environment));
|
|
return await query.OrderByDescending(item => item.Version).Select(item => ToItem(item))
|
|
.ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformConfigurationVersionItem> SaveConfigurationDraftAsync(
|
|
PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformConfigurationManage);
|
|
var definition =
|
|
await platformControlPlanePersistence.PlatformConfigurationDefinitions.SingleOrDefaultAsync(
|
|
item => item.Code == command.DefinitionCode, cancellationToken)
|
|
?? throw Error("Configuration definition was not found.", "platform_configuration_not_found");
|
|
if (!definition.AllowRuntimeManagement)
|
|
throw Error("Security-controlled configuration cannot be changed at runtime.",
|
|
"platform_configuration_runtime_forbidden");
|
|
var environment = NormalizeEnvironment(command.Environment);
|
|
ValidateValue(definition, command.Value, command.SecretRef);
|
|
var nextVersion = (await platformControlPlanePersistence.PlatformConfigurationVersions
|
|
.Where(item => item.DefinitionId == definition.Id && item.Environment == environment)
|
|
.MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1;
|
|
var item = new PlatformConfigurationVersion
|
|
{
|
|
DefinitionId = definition.Id,
|
|
Environment = environment,
|
|
Version = nextVersion,
|
|
Value = definition.IsSensitive ? JsonDefaults.Object() : command.Value!.Value.Clone(),
|
|
SecretRef = definition.IsSensitive ? command.SecretRef!.Trim() : null,
|
|
CreatedBy = actor.UserId,
|
|
Reason = Required(command.Reason, "reason")
|
|
};
|
|
platformControlPlanePersistence.PlatformConfigurationVersions.Add(item);
|
|
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, "platform.configuration.draft_saved", "platform_configuration_versions", item.Id,
|
|
new { definition.Code, item.Environment, item.Version }, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
public async Task<PlatformConfigurationVersionItem> PublishConfigurationAsync(
|
|
PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformConfigurationManage);
|
|
var item = await platformControlPlanePersistence.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId,
|
|
cancellationToken)
|
|
?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found");
|
|
if (item.Status != PlatformConfigurationVersionStatus.Draft)
|
|
throw Error("Only a draft configuration can be published.", "platform_configuration_not_draft");
|
|
var current = await platformControlPlanePersistence.PlatformConfigurationVersions.Where(value =>
|
|
value.DefinitionId == item.DefinitionId &&
|
|
value.Environment == item.Environment && value.Status == PlatformConfigurationVersionStatus.Published)
|
|
.ToArrayAsync(cancellationToken);
|
|
foreach (var published in current) published.Status = PlatformConfigurationVersionStatus.Retired;
|
|
item.Status = PlatformConfigurationVersionStatus.Published;
|
|
item.PublishedBy = actor.UserId;
|
|
item.PublishedAt = DateTimeOffset.UtcNow;
|
|
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, "platform.configuration.published", "platform_configuration_versions", item.Id,
|
|
new { item.DefinitionId, item.Environment, item.Version }, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
public async Task<PlatformConfigurationVersionItem> RollbackConfigurationAsync(
|
|
PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformConfigurationManage);
|
|
var source = await platformControlPlanePersistence.PlatformConfigurationVersions.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.Id == versionId, cancellationToken)
|
|
?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found");
|
|
var nextVersion = (await platformControlPlanePersistence.PlatformConfigurationVersions.Where(item =>
|
|
item.DefinitionId == source.DefinitionId && item.Environment == source.Environment)
|
|
.MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1;
|
|
var rollback = new PlatformConfigurationVersion
|
|
{
|
|
DefinitionId = source.DefinitionId,
|
|
Environment = source.Environment,
|
|
Version = nextVersion,
|
|
Value = source.Value.Clone(),
|
|
SecretRef = source.SecretRef,
|
|
CreatedBy = actor.UserId,
|
|
RolledBackFromVersionId = source.Id,
|
|
Reason = Required(reason, "reason")
|
|
};
|
|
platformControlPlanePersistence.PlatformConfigurationVersions.Add(rollback);
|
|
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
return await PublishConfigurationAsync(actor, rollback.Id, cancellationToken);
|
|
}
|
|
|
|
public async Task<PagedResult<PlatformNotificationDeliveryItem>> GetNotificationDeliveriesAsync(
|
|
PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformNotificationManage);
|
|
var values = platformControlPlanePersistence.PlatformNotificationDeliveries.AsNoTracking();
|
|
if (status.HasValue) values = values.Where(item => item.Status == status.Value);
|
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
|
values = values.Where(item =>
|
|
item.Subject.Contains(query.Search) || item.RecipientRoleCode.Contains(query.Search));
|
|
var total = await values.CountAsync(cancellationToken);
|
|
var items = await values.OrderByDescending(item => item.CreatedAt)
|
|
.Skip((query.SafePage - 1) * query.SafePageSize).Take(query.SafePageSize)
|
|
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
|
|
return new PagedResult<PlatformNotificationDeliveryItem>(items, total, query.SafePage, query.SafePageSize);
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<PlatformNotificationTemplateItem>> GetNotificationTemplatesAsync(
|
|
PlatformApprovalActor actor, CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformNotificationManage);
|
|
return await platformControlPlanePersistence.PlatformNotificationTemplates.AsNoTracking().OrderBy(item => item.Code)
|
|
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformNotificationTemplateItem> UpsertNotificationTemplateAsync(
|
|
PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformNotificationManage);
|
|
var code = Required(command.Code, "code").ToLowerInvariant();
|
|
var item = command.Id.HasValue
|
|
? await platformControlPlanePersistence.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id,
|
|
cancellationToken)
|
|
: await platformControlPlanePersistence.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code,
|
|
cancellationToken);
|
|
item ??= new PlatformNotificationTemplate { Code = code };
|
|
if (platformControlPlanePersistence.Entry(item).State == EntityState.Detached) platformControlPlanePersistence.PlatformNotificationTemplates.Add(item);
|
|
item.Name = Required(command.Name, "name");
|
|
item.Channel = command.Channel;
|
|
item.SubjectTemplate = Required(command.SubjectTemplate, "subjectTemplate");
|
|
item.BodyTemplate = Required(command.BodyTemplate, "bodyTemplate");
|
|
item.Enabled = command.Enabled;
|
|
item.Variables = command.Variables.Clone();
|
|
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, "platform.notification_template.upserted", "platform_notification_templates",
|
|
item.Id, new { item.Code, item.Channel }, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<PlatformNotificationDeliveryItem>> SendNotificationAsync(
|
|
PlatformApprovalActor actor, SendPlatformNotificationCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformNotificationManage);
|
|
var template = await platformControlPlanePersistence.PlatformNotificationTemplates.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.Id == command.TemplateId, cancellationToken)
|
|
?? throw Error("Notification template was not found.",
|
|
"platform_notification_template_not_found");
|
|
if (!template.Enabled)
|
|
throw Error("Notification template is disabled.", "platform_notification_template_disabled");
|
|
var roles = command.RoleCodes.Select(value => Required(value, "roleCode")).Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
var recipients = await (from binding in jobsOperationsPersistence.PlatformBackendUserRoles.AsNoTracking()
|
|
join role in jobsOperationsPersistence.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
|
|
where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active
|
|
select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken);
|
|
var subject = Render(template.SubjectTemplate, command.Variables);
|
|
var body = Render(template.BodyTemplate, command.Variables);
|
|
var deliveries = new List<PlatformNotificationDelivery>();
|
|
foreach (var recipient in recipients)
|
|
{
|
|
var existing = await platformControlPlanePersistence.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item =>
|
|
item.TemplateId == template.Id &&
|
|
item.RecipientUserId == recipient.UserId && item.IdempotencyKey == command.IdempotencyKey,
|
|
cancellationToken);
|
|
if (existing) continue;
|
|
var delivery = new PlatformNotificationDelivery
|
|
{
|
|
TemplateId = template.Id,
|
|
RecipientUserId = recipient.UserId,
|
|
RecipientRoleCode = recipient.RoleCode,
|
|
Channel = template.Channel,
|
|
Subject = subject,
|
|
Body = body,
|
|
IdempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"),
|
|
CreatedBy = actor.UserId,
|
|
Status = template.Channel == PlatformNotificationChannel.InApp
|
|
? PlatformNotificationDeliveryStatus.Sent
|
|
: PlatformNotificationDeliveryStatus.Pending,
|
|
SentAt = template.Channel == PlatformNotificationChannel.InApp ? DateTimeOffset.UtcNow : null
|
|
};
|
|
platformControlPlanePersistence.PlatformNotificationDeliveries.Add(delivery);
|
|
deliveries.Add(delivery);
|
|
}
|
|
|
|
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, "platform.notification.sent", "platform_notification_templates", template.Id,
|
|
new { template.Code, roles, recipients = deliveries.Count, template.Channel }, cancellationToken);
|
|
return deliveries.Select(ToItem).ToArray();
|
|
}
|
|
|
|
public async Task<PlatformNotificationDeliveryItem> RetryNotificationAsync(
|
|
PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformNotificationManage);
|
|
var item = await platformControlPlanePersistence.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId,
|
|
cancellationToken)
|
|
?? throw Error("Notification delivery was not found.", "platform_notification_delivery_not_found");
|
|
if (item.Status is not (PlatformNotificationDeliveryStatus.Failed
|
|
or PlatformNotificationDeliveryStatus.Pending))
|
|
throw Error("Only pending or failed notification delivery can be retried.",
|
|
"platform_notification_not_retryable");
|
|
item.Status = PlatformNotificationDeliveryStatus.Pending;
|
|
item.Attempts++;
|
|
item.LastError = null;
|
|
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, "platform.notification.retry_requested", "platform_notification_deliveries",
|
|
item.Id, new { item.Channel, item.Attempts }, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return auditService.WriteAsync(
|
|
new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"),
|
|
JsonSerializer.SerializeToElement(details)), cancellationToken);
|
|
}
|
|
|
|
private static void Require(PlatformApprovalActor actor, string permission)
|
|
{
|
|
if (!actor.Permissions.Contains(permission))
|
|
throw Error("Platform governance access is denied.", "platform_access_denied");
|
|
}
|
|
|
|
private static string Required(string? value, string field)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
|
|
}
|
|
|
|
private static string NormalizeEnvironment(string value)
|
|
{
|
|
value = Required(value, "environment").ToLowerInvariant();
|
|
return EnvironmentPattern().IsMatch(value)
|
|
? value
|
|
: throw Error("Environment code is invalid.", "platform_configuration_environment_invalid");
|
|
}
|
|
|
|
private static void ValidateValue(PlatformConfigurationDefinition definition, JsonElement? value, string? secretRef)
|
|
{
|
|
if (definition.IsSensitive && string.IsNullOrWhiteSpace(secretRef))
|
|
throw Error("Sensitive configuration requires a secret reference.",
|
|
"platform_configuration_secret_ref_required");
|
|
if (definition.IsSensitive && value.HasValue &&
|
|
value.Value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.Object))
|
|
throw Error("Sensitive configuration cannot contain a plain value.",
|
|
"platform_configuration_plain_secret_forbidden");
|
|
if (!definition.IsSensitive && !string.IsNullOrWhiteSpace(secretRef))
|
|
throw Error("Non-sensitive configuration cannot use a secret reference.",
|
|
"platform_configuration_secret_ref_invalid");
|
|
if (!definition.IsSensitive && !value.HasValue)
|
|
throw Error("Configuration value is required.", "platform_configuration_value_required");
|
|
}
|
|
|
|
private static string Render(string template, IReadOnlyDictionary<string, string> variables)
|
|
{
|
|
return TokenPattern().Replace(template, match =>
|
|
variables.TryGetValue(match.Groups[1].Value, out var value)
|
|
? value
|
|
: throw Error($"Notification variable {match.Groups[1].Value} is missing.",
|
|
"platform_notification_variable_missing"));
|
|
}
|
|
|
|
private static PlatformApprovalException Error(string message, string code)
|
|
{
|
|
return new PlatformApprovalException(message, code);
|
|
}
|
|
|
|
private static PlatformConfigurationDefinitionItem ToItem(PlatformConfigurationDefinition item)
|
|
{
|
|
return new PlatformConfigurationDefinitionItem(item.Id, item.Code, item.Name, item.Category, item.ValueType,
|
|
item.AllowRuntimeManagement,
|
|
item.IsSensitive, item.Description, item.ValidationSchema);
|
|
}
|
|
|
|
private static PlatformConfigurationVersionItem ToItem(PlatformConfigurationVersion item)
|
|
{
|
|
return new PlatformConfigurationVersionItem(item.Id, item.DefinitionId, item.Environment, item.Version,
|
|
item.Status,
|
|
item.SecretRef is null ? item.Value : null, item.SecretRef, item.CreatedBy, item.PublishedBy,
|
|
item.RolledBackFromVersionId, item.Reason, item.PublishedAt, item.CreatedAt);
|
|
}
|
|
|
|
private static PlatformNotificationTemplateItem ToItem(PlatformNotificationTemplate item)
|
|
{
|
|
return new PlatformNotificationTemplateItem(item.Id, item.Code, item.Name, item.Channel, item.SubjectTemplate,
|
|
item.BodyTemplate, item.Enabled,
|
|
item.Variables, item.UpdatedAt);
|
|
}
|
|
|
|
private static PlatformNotificationDeliveryItem ToItem(PlatformNotificationDelivery item)
|
|
{
|
|
return new PlatformNotificationDeliveryItem(item.Id, item.TemplateId, item.RecipientUserId,
|
|
item.RecipientRoleCode, item.Channel, item.Status,
|
|
item.Subject, item.Body, item.Attempts, item.LastError, item.SentAt, item.CreatedAt);
|
|
}
|
|
|
|
[GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,79}$")]
|
|
private static partial Regex EnvironmentPattern();
|
|
|
|
[GeneratedRegex("\\{\\{([a-zA-Z][a-zA-Z0-9_.-]*)\\}\\}")]
|
|
private static partial Regex TokenPattern();
|
|
} |