511 lines
25 KiB
C#
511 lines
25 KiB
C#
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Backoffice;
|
|
using Tiku.Application.PlatformAdmin;
|
|
using Tiku.Application.PlatformBilling;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.PlatformAdmin;
|
|
|
|
internal sealed class PlatformApprovalService(
|
|
TikuDbContext dbContext,
|
|
IServiceScopeFactory scopeFactory,
|
|
ITenantExecutionScope tenantExecutionScope,
|
|
IOperationAuditService auditService,
|
|
IPlatformBillingAdminService billingService,
|
|
IPlatformAdminService platformAdminService,
|
|
IPlatformPaymentSettingsService paymentSettingsService,
|
|
IBackofficeService backofficeService) : IPlatformApprovalService
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
public async Task<IReadOnlyCollection<PlatformApprovalRequestItem>> ListAsync(
|
|
PlatformApprovalActor actor,
|
|
PlatformApprovalRequestStatus? status,
|
|
int limit,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformApprovalView);
|
|
await ExpirePendingAsync(cancellationToken);
|
|
var query = dbContext.PlatformApprovalRequests.AsNoTracking();
|
|
if (status.HasValue) query = query.Where(item => item.Status == status.Value);
|
|
return await query.OrderByDescending(item => item.CreatedAt)
|
|
.Take(Math.Clamp(limit, 1, 500))
|
|
.Select(item => ToItem(item))
|
|
.ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformApprovalRequestItem> GetAsync(
|
|
PlatformApprovalActor actor,
|
|
Guid requestId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformApprovalView);
|
|
var item = await RequiredRequestAsync(requestId, cancellationToken);
|
|
if (item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= DateTimeOffset.UtcNow)
|
|
{
|
|
item.Status = PlatformApprovalRequestStatus.Expired;
|
|
item.ConcurrencyStamp = Guid.NewGuid();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
return ToItem(item);
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<PlatformApprovalPolicyItem>> ListPoliciesAsync(
|
|
PlatformApprovalActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformApprovalView);
|
|
return await dbContext.PlatformApprovalPolicies.AsNoTracking()
|
|
.OrderBy(item => item.Code)
|
|
.Select(item => ToItem(item))
|
|
.ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformApprovalPolicyItem> UpdatePolicyAsync(
|
|
PlatformApprovalActor actor,
|
|
UpdatePlatformApprovalPolicyCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformApprovalPolicyManage);
|
|
var policy =
|
|
await dbContext.PlatformApprovalPolicies.SingleOrDefaultAsync(item => item.Code == command.Code,
|
|
cancellationToken)
|
|
?? throw Error("Approval policy was not found.", "approval_policy_not_found");
|
|
if (command.ExpiresAfterHours is < 1 or > 720 || command.AmountThresholdCents is <= 0)
|
|
throw Error("Approval policy limits are invalid.", "approval_policy_invalid");
|
|
policy.Enabled = command.Enabled;
|
|
policy.AlwaysRequireApproval = command.AlwaysRequireApproval;
|
|
policy.AmountThresholdCents = command.AmountThresholdCents;
|
|
policy.ExpiresAfterHours = command.ExpiresAfterHours;
|
|
policy.Conditions = command.Conditions.Clone();
|
|
policy.Version++;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, "platform.approval_policy.updated", "platform_approval_policies", policy.Id,
|
|
new { policy.Code, policy.Version }, cancellationToken);
|
|
return ToItem(policy);
|
|
}
|
|
|
|
public Task<PlatformApprovalRequestItem> ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return DecideAsync(actor, requestId, true, reason, cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformApprovalRequestItem> RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return DecideAsync(actor, requestId, false, reason, cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformApprovalRequestItem> CancelAsync(
|
|
PlatformApprovalActor actor,
|
|
Guid requestId,
|
|
string reason,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var item = await RequiredRequestAsync(requestId, cancellationToken);
|
|
if (item.RequestedBy != actor.UserId)
|
|
throw Error("Only the requester can cancel an approval request.", "approval_cancel_denied");
|
|
EnsurePending(item);
|
|
item.Status = PlatformApprovalRequestStatus.Cancelled;
|
|
item.DecisionReason = RequiredReason(reason);
|
|
item.DecidedAt = DateTimeOffset.UtcNow;
|
|
item.ConcurrencyStamp = Guid.NewGuid();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, "platform.approval.cancelled", "platform_approval_requests", item.Id,
|
|
new { item.RequestNo }, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
public Task<PlatformCommandSubmission> SubmitRefundAsync(
|
|
SaasCatalogActor actor,
|
|
RequestPlatformRefundCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.FinancialAdjustment,
|
|
nameof(RequestPlatformRefundCommand), "platform_billing_payments", command.PaymentId.ToString("N"),
|
|
command.AmountCents, command.IdempotencyKey, command.Reason, command,
|
|
async token => await billingService.RequestRefundAsync(actor, command, token), cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformCommandSubmission> ConfirmManualPaymentAsync(
|
|
SaasCatalogActor actor,
|
|
ConfirmManualPaymentCommand command,
|
|
string idempotencyKey,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var amount = await tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
null,
|
|
SystemScopeCallerType.Platform,
|
|
nameof(PlatformApprovalService),
|
|
"Resolve payment amount for platform approval",
|
|
command.PaymentId.ToString("N"),
|
|
true),
|
|
async (services, token) => await services.GetRequiredService<TikuDbContext>()
|
|
.PlatformBillingPayments.AsNoTracking()
|
|
.Where(item => item.Id == command.PaymentId)
|
|
.Select(item => (int?)item.AmountCents)
|
|
.SingleOrDefaultAsync(token),
|
|
cancellationToken)
|
|
?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found");
|
|
return await SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.FinancialAdjustment,
|
|
nameof(ConfirmManualPaymentCommand), "platform_billing_payments", command.PaymentId.ToString("N"),
|
|
amount, idempotencyKey, command.Reason, command,
|
|
async token => await billingService.ConfirmManualPaymentAsync(actor, command, token), cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformCommandSubmission> UpdateTenantStatusAsync(
|
|
PlatformAdminActor actor,
|
|
UpdatePlatformTenantStatusCommand command,
|
|
string idempotencyKey,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (command.Status != TenantStatus.Archived)
|
|
return ExecuteImmediateAsync(() =>
|
|
platformAdminService.UpdateTenantStatusAsync(actor, command, cancellationToken));
|
|
return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.TenantArchive,
|
|
nameof(UpdatePlatformTenantStatusCommand), "tenants", command.TenantId.ToString("N"), null,
|
|
idempotencyKey, command.Reason, command,
|
|
async token => await platformAdminService.UpdateTenantStatusAsync(actor, command, token),
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<PlatformCommandSubmission> UpsertPaymentChannelAsync(
|
|
PlatformCapabilityActor actor,
|
|
UpsertPlatformPaymentChannelCommand command,
|
|
string idempotencyKey,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.PaymentChannelChange,
|
|
nameof(UpsertPlatformPaymentChannelCommand), "platform_payment_channels",
|
|
command.Id?.ToString("N") ?? command.Provider,
|
|
null, idempotencyKey, "支付渠道或密钥引用变更", command,
|
|
async token => await paymentSettingsService.UpsertChannelAsync(actor, command, token), cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformCommandSubmission> ReplaceRoleBindingsAsync(
|
|
BackofficeActor actor,
|
|
ReplaceRoleBindingsCommand command,
|
|
string idempotencyKey,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var isSuperAdmin = await dbContext.PlatformBackendRoles.AsNoTracking()
|
|
.AnyAsync(role => role.Id == command.RoleId && role.Code == "platform_super_admin", cancellationToken);
|
|
if (!isSuperAdmin)
|
|
return await ExecuteImmediateAsync(() =>
|
|
backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, cancellationToken));
|
|
return await SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.SuperAdminGrant,
|
|
nameof(ReplaceRoleBindingsCommand), "platform_backend_roles", command.RoleId.ToString("N"), null,
|
|
idempotencyKey, "平台超级管理员权限变更", command,
|
|
async token => await backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, token),
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<int> ProcessApprovedAsync(int batchSize = 20, CancellationToken cancellationToken = default)
|
|
{
|
|
var requestIds = await dbContext.PlatformApprovalRequests.AsNoTracking()
|
|
.Where(item => item.Status == PlatformApprovalRequestStatus.Approved)
|
|
.OrderBy(item => item.DecidedAt)
|
|
.Select(item => item.Id)
|
|
.Take(Math.Clamp(batchSize, 1, 100))
|
|
.ToArrayAsync(cancellationToken);
|
|
var processed = 0;
|
|
foreach (var requestId in requestIds)
|
|
{
|
|
var claimed = await dbContext.PlatformApprovalRequests
|
|
.Where(item => item.Id == requestId && item.Status == PlatformApprovalRequestStatus.Approved)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.Status, PlatformApprovalRequestStatus.Executing)
|
|
.SetProperty(item => item.ConcurrencyStamp, Guid.NewGuid())
|
|
.SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), cancellationToken);
|
|
if (claimed == 0) continue;
|
|
|
|
dbContext.ChangeTracker.Clear();
|
|
var item = await RequiredRequestAsync(requestId, cancellationToken);
|
|
try
|
|
{
|
|
item.ResultSnapshot = await ExecuteApprovedAsync(item, cancellationToken);
|
|
item.Status = PlatformApprovalRequestStatus.Succeeded;
|
|
item.ExecutedAt = DateTimeOffset.UtcNow;
|
|
item.Error = null;
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
item.Status = PlatformApprovalRequestStatus.Failed;
|
|
item.Error = exception.Message.Length > 4000 ? exception.Message[..4000] : exception.Message;
|
|
}
|
|
|
|
item.ConcurrencyStamp = Guid.NewGuid();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(item.DecidedBy ?? item.RequestedBy,
|
|
item.Status == PlatformApprovalRequestStatus.Succeeded
|
|
? "platform.approval.executed"
|
|
: "platform.approval.execution_failed",
|
|
"platform_approval_requests", item.Id, new { item.RequestNo, item.CommandType, item.Error },
|
|
cancellationToken);
|
|
processed++;
|
|
}
|
|
|
|
return processed;
|
|
}
|
|
|
|
private async Task<PlatformCommandSubmission> SubmitAsync<TCommand, TResult>(
|
|
Guid actorUserId,
|
|
string policyCode,
|
|
string commandType,
|
|
string targetType,
|
|
string targetId,
|
|
int? amountCents,
|
|
string idempotencyKey,
|
|
string? reason,
|
|
TCommand command,
|
|
Func<CancellationToken, Task<TResult>> execute,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
idempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
|
|
? throw Error("Idempotency-Key is required.", "idempotency_key_required")
|
|
: idempotencyKey.Trim();
|
|
var policy = await dbContext.PlatformApprovalPolicies.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.Code == policyCode, cancellationToken)
|
|
?? throw Error("Approval policy is not configured.", "approval_policy_not_configured");
|
|
var requiresApproval = PlatformApprovalRules.RequiresApproval(
|
|
policy.Enabled, policy.AlwaysRequireApproval, policy.AmountThresholdCents, amountCents);
|
|
if (!requiresApproval)
|
|
return await ExecuteImmediateAsync(() => execute(cancellationToken));
|
|
|
|
var snapshot = RedactedSnapshot(command);
|
|
var requestHash = Hash(snapshot.GetRawText());
|
|
var existing = await dbContext.PlatformApprovalRequests.AsNoTracking().SingleOrDefaultAsync(item =>
|
|
item.RequestedBy == actorUserId && item.CommandType == commandType &&
|
|
item.IdempotencyKey == idempotencyKey,
|
|
cancellationToken);
|
|
if (existing is not null)
|
|
{
|
|
if (!string.Equals(existing.RequestHash, requestHash, StringComparison.Ordinal))
|
|
throw Error("Idempotency key was used with a different approval request.", "idempotency_conflict");
|
|
return new PlatformCommandSubmission(
|
|
existing.Status == PlatformApprovalRequestStatus.Succeeded ? "executed" : "pending_approval",
|
|
existing.ResultSnapshot, ToItem(existing));
|
|
}
|
|
|
|
var item = new PlatformApprovalRequest
|
|
{
|
|
RequestNo = $"PA{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..24],
|
|
PolicyCode = policy.Code,
|
|
PolicyVersion = policy.Version,
|
|
RequestedBy = actorUserId,
|
|
RequiredPermission = policy.RequiredPermission,
|
|
CommandType = commandType,
|
|
TargetType = targetType,
|
|
TargetId = targetId,
|
|
AmountCents = amountCents,
|
|
IdempotencyKey = idempotencyKey,
|
|
RequestHash = requestHash,
|
|
RequestSnapshot = snapshot,
|
|
RequestReason = reason?.Trim(),
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddHours(policy.ExpiresAfterHours)
|
|
};
|
|
dbContext.PlatformApprovalRequests.Add(item);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actorUserId, "platform.approval.requested", "platform_approval_requests", item.Id,
|
|
new { item.RequestNo, item.PolicyCode, item.CommandType, item.TargetType, item.TargetId, item.AmountCents },
|
|
cancellationToken);
|
|
return new PlatformCommandSubmission("pending_approval", null, ToItem(item));
|
|
}
|
|
|
|
private async Task<PlatformApprovalRequestItem> DecideAsync(
|
|
PlatformApprovalActor actor,
|
|
Guid requestId,
|
|
bool approve,
|
|
string reason,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Require(actor, BackendPermissions.PlatformApprovalDecide);
|
|
var item = await RequiredRequestAsync(requestId, cancellationToken);
|
|
var denial = PlatformApprovalRules.DecisionDenialCode(item.Status, item.RequestedBy, actor.UserId,
|
|
item.ExpiresAt, item.RequiredPermission, actor.Permissions, DateTimeOffset.UtcNow);
|
|
if (denial == "approval_request_expired")
|
|
{
|
|
item.Status = PlatformApprovalRequestStatus.Expired;
|
|
item.ConcurrencyStamp = Guid.NewGuid();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
throw Error("Approval request has expired.", "approval_request_expired");
|
|
}
|
|
|
|
if (denial == "approval_request_not_pending")
|
|
throw Error("Only a pending approval request can be changed.", denial);
|
|
if (denial == "approval_maker_checker_required")
|
|
throw Error("Requester cannot approve or reject the same request.", "approval_maker_checker_required");
|
|
if (denial == "approval_business_permission_required")
|
|
throw Error("Approver no longer has the required business permission.",
|
|
"approval_business_permission_required");
|
|
|
|
item.DecidedBy = actor.UserId;
|
|
item.DecidedAt = DateTimeOffset.UtcNow;
|
|
item.DecisionReason = RequiredReason(reason);
|
|
item.Status = approve ? PlatformApprovalRequestStatus.Approved : PlatformApprovalRequestStatus.Rejected;
|
|
item.ConcurrencyStamp = Guid.NewGuid();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor.UserId, approve ? "platform.approval.approved" : "platform.approval.rejected",
|
|
"platform_approval_requests", item.Id, new { item.RequestNo, item.PolicyCode }, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
private async Task<JsonElement> ExecuteApprovedAsync(PlatformApprovalRequest item,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = scopeFactory.CreateAsyncScope();
|
|
scope.ServiceProvider.GetRequiredService<ICurrentUser>().Load(new ClaimsPrincipal(
|
|
new ClaimsIdentity(
|
|
[new Claim(TikuClaimTypes.UserId, item.RequestedBy.ToString())],
|
|
"platform-approval")));
|
|
object result = item.CommandType switch
|
|
{
|
|
nameof(RequestPlatformRefundCommand) => await scope.ServiceProvider
|
|
.GetRequiredService<IPlatformBillingAdminService>()
|
|
.RequestRefundAsync(new SaasCatalogActor(item.RequestedBy),
|
|
Deserialize<RequestPlatformRefundCommand>(item), cancellationToken),
|
|
nameof(ConfirmManualPaymentCommand) => await scope.ServiceProvider
|
|
.GetRequiredService<IPlatformBillingAdminService>()
|
|
.ConfirmManualPaymentAsync(new SaasCatalogActor(item.RequestedBy),
|
|
Deserialize<ConfirmManualPaymentCommand>(item), cancellationToken),
|
|
nameof(UpdatePlatformTenantStatusCommand) => await scope.ServiceProvider
|
|
.GetRequiredService<IPlatformAdminService>()
|
|
.UpdateTenantStatusAsync(new PlatformAdminActor(item.RequestedBy),
|
|
Deserialize<UpdatePlatformTenantStatusCommand>(item), cancellationToken),
|
|
nameof(UpsertPlatformPaymentChannelCommand) => await scope.ServiceProvider
|
|
.GetRequiredService<IPlatformPaymentSettingsService>()
|
|
.UpsertChannelAsync(new PlatformCapabilityActor(item.RequestedBy),
|
|
Deserialize<UpsertPlatformPaymentChannelCommand>(item), cancellationToken),
|
|
nameof(ReplaceRoleBindingsCommand) => await scope.ServiceProvider.GetRequiredService<IBackofficeService>()
|
|
.ReplacePlatformRoleBindingsAsync(new BackofficeActor(item.RequestedBy, null, true),
|
|
Deserialize<ReplaceRoleBindingsCommand>(item), cancellationToken),
|
|
_ => throw Error("Approval command type is not supported.", "approval_command_not_supported")
|
|
};
|
|
return JsonSerializer.SerializeToElement(result, JsonOptions);
|
|
}
|
|
|
|
private static async Task<PlatformCommandSubmission> ExecuteImmediateAsync<TResult>(Func<Task<TResult>> execute)
|
|
{
|
|
var result = await execute();
|
|
return new PlatformCommandSubmission("executed", JsonSerializer.SerializeToElement(result, JsonOptions), null);
|
|
}
|
|
|
|
private static T Deserialize<T>(PlatformApprovalRequest item)
|
|
{
|
|
return JsonSerializer.Deserialize<T>(item.RequestSnapshot.GetRawText(), JsonOptions)
|
|
?? throw Error("Approval request snapshot is invalid.", "approval_snapshot_invalid");
|
|
}
|
|
|
|
private async Task<PlatformApprovalRequest> RequiredRequestAsync(Guid requestId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await dbContext.PlatformApprovalRequests.SingleOrDefaultAsync(item => item.Id == requestId,
|
|
cancellationToken)
|
|
?? throw Error("Approval request was not found.", "approval_request_not_found");
|
|
}
|
|
|
|
private async Task ExpirePendingAsync(CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
await dbContext.PlatformApprovalRequests
|
|
.Where(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.Status, PlatformApprovalRequestStatus.Expired)
|
|
.SetProperty(item => item.ConcurrencyStamp, Guid.NewGuid())
|
|
.SetProperty(item => item.UpdatedAt, now), cancellationToken);
|
|
}
|
|
|
|
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, JsonOptions)), cancellationToken);
|
|
}
|
|
|
|
private static JsonElement RedactedSnapshot<T>(T command)
|
|
{
|
|
var node = JsonSerializer.SerializeToNode(command, JsonOptions) ?? new JsonObject();
|
|
Redact(node);
|
|
return JsonSerializer.SerializeToElement(node, JsonOptions);
|
|
}
|
|
|
|
private static void Redact(JsonNode? node)
|
|
{
|
|
if (node is JsonObject value)
|
|
foreach (var property in value.ToArray())
|
|
{
|
|
var name = property.Key;
|
|
if ((name.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Contains("token", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Equals("secret", StringComparison.OrdinalIgnoreCase)) &&
|
|
!name.EndsWith("Ref", StringComparison.OrdinalIgnoreCase))
|
|
value[name] = "***";
|
|
else Redact(property.Value);
|
|
}
|
|
else if (node is JsonArray array)
|
|
foreach (var item in array)
|
|
Redact(item);
|
|
}
|
|
|
|
private static string Hash(string value)
|
|
{
|
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
|
}
|
|
|
|
private static string RequiredReason(string reason)
|
|
{
|
|
return string.IsNullOrWhiteSpace(reason)
|
|
? throw Error("Decision reason is required.", "approval_reason_required")
|
|
: reason.Trim();
|
|
}
|
|
|
|
private static void EnsurePending(PlatformApprovalRequest item)
|
|
{
|
|
if (item.Status != PlatformApprovalRequestStatus.Pending)
|
|
throw Error("Only a pending approval request can be changed.", "approval_request_not_pending");
|
|
}
|
|
|
|
private static void Require(PlatformApprovalActor actor, string permission)
|
|
{
|
|
if (!actor.Permissions.Contains(permission))
|
|
throw Error("Platform approval access is denied.", "platform_access_denied");
|
|
}
|
|
|
|
private static PlatformApprovalException Error(string message, string code)
|
|
{
|
|
return new PlatformApprovalException(message, code);
|
|
}
|
|
|
|
private static PlatformApprovalRequestItem ToItem(PlatformApprovalRequest item)
|
|
{
|
|
return new PlatformApprovalRequestItem(item.Id, item.RequestNo, item.PolicyCode, item.PolicyVersion,
|
|
item.Status, item.RequestedBy, item.DecidedBy, item.RequiredPermission, item.CommandType, item.TargetType,
|
|
item.TargetId, item.AmountCents,
|
|
item.RequestSnapshot, item.RequestReason, item.DecisionReason, item.Error, item.ExpiresAt, item.CreatedAt,
|
|
item.UpdatedAt);
|
|
}
|
|
|
|
private static PlatformApprovalPolicyItem ToItem(PlatformApprovalPolicy item)
|
|
{
|
|
return new PlatformApprovalPolicyItem(item.Id, item.Code, item.Name, item.RequiredPermission,
|
|
item.Enabled, item.AlwaysRequireApproval, item.AmountThresholdCents, item.Version, item.ExpiresAfterHours,
|
|
item.Conditions);
|
|
}
|
|
} |