Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,10 @@
|
||||
namespace ItWorx.KnowledgeHub.Api.Authorization;
|
||||
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string RequireReader = "RequireReader";
|
||||
public const string RequireEditor = "RequireEditor";
|
||||
public const string RequireAdministrator = "RequireAdministrator";
|
||||
public const string IngestionWrite = "IngestionWrite";
|
||||
public const string IngestionReview = "IngestionReview";
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Two independent success paths, checked in order — a service-token claim never needs
|
||||
/// a database round trip for the common case, and a cookie-authenticated human is
|
||||
/// resolved live from the database exactly as <c>WorkspaceRoleAuthorizationHandler</c>
|
||||
/// already does (never from a stale role claim baked into the cookie).
|
||||
/// </summary>
|
||||
public sealed class ContentAccessAuthorizationHandler(
|
||||
IUserAccountManager userAccounts,
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships)
|
||||
: AuthorizationHandler<ContentAccessRequirement>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ContentAccessRequirement requirement)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.User.HasClaim(ServiceTokenAuthenticationHandler.ScopeClaimType, requirement.RequiredScope))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
// A service-token principal that lacks the required scope must not fall
|
||||
// through to the human/cookie path below — GetSignedInAccountAsync would
|
||||
// legitimately return null for it anyway, but failing fast here keeps the two
|
||||
// paths clearly separated.
|
||||
if (context.User.HasClaim(claim => claim.Type == ServiceTokenAuthenticationHandler.ServiceAccountClaimType))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var account = await userAccounts.GetSignedInAccountAsync(CancellationToken.None);
|
||||
if (account is null || !account.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var role = await memberships.GetRoleAsync(workspaceId.Value, account.Id, CancellationToken.None);
|
||||
if (role is not null && role.Value >= requirement.MinimumRole)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Authorization;
|
||||
|
||||
public static class ContentAccessEndpointExtensions
|
||||
{
|
||||
/// <summary>Requires either the given minimum cookie-session workspace role, or a
|
||||
/// bearer service-token carrying <paramref name="scope"/> — the same endpoint and
|
||||
/// route serves both the browser SPA and scoped automation
|
||||
/// (docs/09-api-contract.md, docs/24-mcp-readiness.md).
|
||||
///
|
||||
/// Both schemes must be listed explicitly: a policy with an explicit
|
||||
/// <c>AuthenticationSchemes</c> list re-authenticates using *only* those schemes
|
||||
/// for that check, rather than reusing whatever <c>app.UseAuthentication()</c>'s
|
||||
/// default-scheme pass already populated on <c>HttpContext.User</c> — omitting the
|
||||
/// cookie scheme here silently rejected every cookie-authenticated (browser)
|
||||
/// caller with a 401, found via a real integration test failure across the whole
|
||||
/// Automation test suite, not by inspection.</summary>
|
||||
public static TBuilder RequireContentAccess<TBuilder>(this TBuilder builder, WorkspaceRole minimumRole, string scope)
|
||||
where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(policy => policy
|
||||
.AddAuthenticationSchemes(
|
||||
Microsoft.AspNetCore.Identity.IdentityConstants.ApplicationScheme,
|
||||
ServiceTokenAuthenticationDefaults.Scheme)
|
||||
.Requirements.Add(new ContentAccessRequirement(minimumRole, scope)));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Satisfied by either a cookie-authenticated human whose workspace role is at least
|
||||
/// <see cref="MinimumRole"/> (the original M1 <c>WorkspaceRoleRequirement</c> check), or
|
||||
/// a bearer-token-authenticated service account carrying <see cref="RequiredScope"/>.
|
||||
/// Scopes never bypass this — a service token with the right scope still only reaches
|
||||
/// exactly the endpoints it is checked against, and every endpoint independently
|
||||
/// re-verifies (M6-A02: "tested against horizontal and vertical privilege
|
||||
/// escalation").
|
||||
/// </summary>
|
||||
public sealed class ContentAccessRequirement(WorkspaceRole minimumRole, string requiredScope) : IAuthorizationRequirement
|
||||
{
|
||||
public WorkspaceRole MinimumRole { get; } = minimumRole;
|
||||
|
||||
public string RequiredScope { get; } = requiredScope;
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Authorization;
|
||||
|
||||
public static class ServiceTokenAuthenticationDefaults
|
||||
{
|
||||
public const string Scheme = "ServiceToken";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates <c>Authorization: Bearer ikh_<public-id>_<secret></c>
|
||||
/// requests against the database on every call (docs/09-api-contract.md "Service
|
||||
/// account"). On success, issues a claims principal carrying the service account's ID
|
||||
/// as the standard name identifier (so existing `CurrentUserId(principal)` endpoint
|
||||
/// helpers keep working unmodified for audit attribution) plus one claim per granted
|
||||
/// scope, checked by <see cref="ContentAccessRequirement"/>.
|
||||
/// </summary>
|
||||
public sealed class ServiceTokenAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
AuthenticateServiceTokenUseCase authenticate,
|
||||
ServiceTokenFailureRateLimiter failureRateLimiter)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string ScopeClaimType = "scope";
|
||||
public const string ServiceAccountClaimType = "service_account_id";
|
||||
private const string RateLimitedItemKey = "ServiceToken.RateLimited";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authorizationHeader))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var headerValue = authorizationHeader.ToString();
|
||||
if (!headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var presentedToken = headerValue["Bearer ".Length..].Trim();
|
||||
// CancellationToken.None, not Context.RequestAborted: this call also writes
|
||||
// (MarkUsed + SaveChangesAsync) — letting an aborted-request token cancel a
|
||||
// write mid-flight risks a half-applied "last used" update, which is
|
||||
// low-value to interrupt anyway.
|
||||
var authenticated = await authenticate.ExecuteAsync(presentedToken, CancellationToken.None);
|
||||
if (authenticated is null)
|
||||
{
|
||||
var clientIp = Context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
if (!failureRateLimiter.TryAcquireOnFailure(clientIp))
|
||||
{
|
||||
Context.Items[RateLimitedItemKey] = true;
|
||||
}
|
||||
|
||||
return AuthenticateResult.Fail("Invalid or revoked service-account token.");
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, authenticated.Account.Id.ToString()),
|
||||
new(ClaimTypes.Name, authenticated.Account.Name),
|
||||
new(ServiceAccountClaimType, authenticated.Account.Id.ToString())
|
||||
};
|
||||
claims.AddRange(authenticated.Token.Scopes.Select(scope => new Claim(ScopeClaimType, scope)));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, ServiceTokenAuthenticationDefaults.Scheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, ServiceTokenAuthenticationDefaults.Scheme);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
if (Context.Items.ContainsKey(RateLimitedItemKey))
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return base.HandleChallengeAsync(properties);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller's role from the database on every check — not from a role claim
|
||||
/// baked into the sign-in cookie — so a role change or account disablement takes effect
|
||||
/// immediately rather than only after the user's next sign-in.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceRoleAuthorizationHandler(
|
||||
IUserAccountManager userAccounts,
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships)
|
||||
: AuthorizationHandler<WorkspaceRoleRequirement>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
WorkspaceRoleRequirement requirement)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var account = await userAccounts.GetSignedInAccountAsync(CancellationToken.None);
|
||||
if (account is null || !account.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var role = await memberships.GetRoleAsync(workspaceId.Value, account.Id, CancellationToken.None);
|
||||
if (role is not null && role.Value >= requirement.MinimumRole)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Authorization;
|
||||
|
||||
/// <summary>Satisfied when the signed-in user's role in the default workspace is at
|
||||
/// least <see cref="MinimumRole"/> (docs/12-auth-security.md — enforced server-side in
|
||||
/// Application/API, never only by hiding UI).</summary>
|
||||
public sealed class WorkspaceRoleRequirement(WorkspaceRole minimumRole) : IAuthorizationRequirement
|
||||
{
|
||||
public WorkspaceRole MinimumRole { get; } = minimumRole;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically reclaims expired, abandoned page-creation staging sessions
|
||||
/// (docs/adr/ADR-029-v2-r5-final-hardening.md). A thin host-lifetime loop only — every
|
||||
/// actual safety property (bounded batch, atomic per-session claim, lock-serialized
|
||||
/// blob garbage collection, per-session error isolation) lives in
|
||||
/// <see cref="CleanupExpiredPageCreationSessionsUseCase"/>, shared with the admin
|
||||
/// "run now" action, so the two paths can never diverge in behavior.
|
||||
/// </summary>
|
||||
public sealed partial class StagingSessionCleanupBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
StagingSessionCleanupOptions options,
|
||||
StagingCleanupStatusTracker statusTracker,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StagingSessionCleanupBackgroundService> logger)
|
||||
: BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
LogDisabled();
|
||||
return;
|
||||
}
|
||||
|
||||
// A short initial delay lets the host finish starting (migrations, health
|
||||
// checks) before the first pass, rather than racing application startup.
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), timeProvider, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await RunOnceAsync("scheduled", stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(options.Interval, timeProvider, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(string trigger, CancellationToken cancellationToken)
|
||||
{
|
||||
var startedAt = timeProvider.GetUtcNow();
|
||||
statusTracker.RecordStart(startedAt, trigger);
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var useCase = scope.ServiceProvider.GetRequiredService<CleanupExpiredPageCreationSessionsUseCase>();
|
||||
var result = await useCase.ExecuteAsync(options.BatchSize, cancellationToken);
|
||||
|
||||
statusTracker.RecordCompletion(timeProvider.GetUtcNow(), result);
|
||||
|
||||
if (result.SessionsClaimed > 0 || result.Errors.Count > 0)
|
||||
{
|
||||
LogRunSummary(result.SessionsClaimed, result.AttachmentReferencesRemoved, result.BlobsRemoved, result.Errors.Count);
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
LogSessionError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
statusTracker.RecordCompletion(timeProvider.GetUtcNow(), StagingCleanupRunResult.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A whole-run failure (e.g. the database is briefly unreachable) never
|
||||
// crashes the host — it is logged and the next scheduled tick simply tries
|
||||
// again; every session-level error is already caught and reported inside
|
||||
// the use case itself.
|
||||
statusTracker.RecordCompletion(
|
||||
timeProvider.GetUtcNow(),
|
||||
new StagingCleanupRunResult(0, 0, 0, 0, 0, [$"Volledige cleanup-run mislukt: {ex.Message}"]));
|
||||
LogRunFailed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "Staging-cleanup-achtergrondtaak is uitgeschakeld via configuratie.")]
|
||||
private partial void LogDisabled();
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "Staging-cleanup: {Claimed} sessie(s) opgeruimd, {References} bijlage(n), {Blobs} blob(s), {Errors} fout(en).")]
|
||||
private partial void LogRunSummary(int claimed, int references, int blobs, int errors);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Staging-cleanup fout: {Error}")]
|
||||
private partial void LogSessionError(string error);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "Staging-cleanup-run mislukt.")]
|
||||
private partial void LogRunFailed(Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Contracts.Activity;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class ActivityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapActivityEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/activity", ListAsync)
|
||||
.WithTags("Activity")
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.AuditRead);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(
|
||||
IAuditEventReader reader,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await reader.ListRecentAsync(limit ?? 100, cancellationToken);
|
||||
return Results.Ok(new ActivityListResponse(items.Select(item => new ActivityItemResponse(
|
||||
item.Id, item.ActorKind, item.ActorId, item.Source, item.Action, item.TargetType,
|
||||
item.TargetId, item.Summary, item.OccurredAt)).ToArray()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class AdminEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/admin")
|
||||
.WithTags("Admin")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
||||
|
||||
group.MapGet("/users", ListUsersAsync);
|
||||
|
||||
group.MapPost("/users", CreateUserAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPatch("/users/{userId:guid}", UpdateUserAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/users/{userId:guid}/recovery-tokens", IssueRecoveryTokenAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/trash/purge", PurgeTrashAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapGet("/staging-cleanup/status", GetStagingCleanupStatusAsync);
|
||||
|
||||
group.MapPost("/staging-cleanup/run", RunStagingCleanupAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<StagingCleanupStatusResponse>> GetStagingCleanupStatusAsync(
|
||||
IPageCreationSessionRepository sessions,
|
||||
StagingCleanupStatusTracker statusTracker,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var activeCount = await sessions.CountOpenSessionsAsync(cancellationToken);
|
||||
var expiredCount = await sessions.CountExpiredOpenSessionsAsync(now, cancellationToken);
|
||||
var snapshot = statusTracker.GetSnapshot();
|
||||
|
||||
return TypedResults.Ok(new StagingCleanupStatusResponse(
|
||||
activeCount, expiredCount, snapshot.RunInProgress, snapshot.LastRunStartedAt, snapshot.LastRunCompletedAt,
|
||||
snapshot.LastRunTrigger, ToSummary(snapshot.LastResult)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RunStagingCleanupResponse>, ProblemHttpResult>> RunStagingCleanupAsync(
|
||||
[FromBody] RunStagingCleanupRequest request,
|
||||
CleanupExpiredPageCreationSessionsUseCase useCase,
|
||||
StagingSessionCleanupOptions options,
|
||||
StagingCleanupStatusTracker statusTracker,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.DryRun)
|
||||
{
|
||||
var preview = await useCase.PreviewAsync(options.BatchSize, cancellationToken);
|
||||
return TypedResults.Ok(new RunStagingCleanupResponse(true, ToSummary(preview)!));
|
||||
}
|
||||
|
||||
statusTracker.RecordStart(timeProvider.GetUtcNow(), "manual");
|
||||
var result = await useCase.ExecuteAsync(options.BatchSize, cancellationToken);
|
||||
statusTracker.RecordCompletion(timeProvider.GetUtcNow(), result);
|
||||
|
||||
return TypedResults.Ok(new RunStagingCleanupResponse(false, ToSummary(result)!));
|
||||
}
|
||||
|
||||
private static StagingCleanupRunSummary? ToSummary(StagingCleanupRunResult? result) => result is null
|
||||
? null
|
||||
: new StagingCleanupRunSummary(
|
||||
result.SessionsExpired, result.SessionsClaimed, result.SessionsLostRace,
|
||||
result.AttachmentReferencesRemoved, result.BlobsRemoved, result.Errors);
|
||||
|
||||
private static async Task<Results<Ok<TrashPurgeResponse>, ProblemHttpResult>> PurgeTrashAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
PurgeTrashUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new PurgeTrashCommand(workspaceId.Value, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new TrashPurgeResponse(result.PurgedCount));
|
||||
}
|
||||
|
||||
private static async Task<Ok<IReadOnlyList<UserSummaryResponse>>> ListUsersAsync(
|
||||
ListWorkspaceUsersUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await useCase.ExecuteAsync(cancellationToken);
|
||||
IReadOnlyList<UserSummaryResponse> response = users
|
||||
.Select(user => new UserSummaryResponse(
|
||||
user.Account.Id,
|
||||
user.Account.DisplayName,
|
||||
user.Account.Email,
|
||||
user.Role.ToString(),
|
||||
user.Account.Enabled,
|
||||
user.Account.CreatedAt,
|
||||
user.Account.LastLoginAt))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UserSummaryResponse>, ProblemHttpResult>> CreateUserAsync(
|
||||
[FromBody] CreateUserRequest request,
|
||||
CreateWorkspaceUserUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var actorId = CurrentUserId(principal);
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
request.DisplayName,
|
||||
request.Email,
|
||||
request.Password,
|
||||
request.Role,
|
||||
actorId,
|
||||
correlationId,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status == CreateWorkspaceUserStatus.Success)
|
||||
{
|
||||
return TypedResults.Ok(new UserSummaryResponse(
|
||||
result.Account!.Id,
|
||||
result.Account.DisplayName,
|
||||
result.Account.Email,
|
||||
result.Role!.Value.ToString(),
|
||||
result.Account.Enabled,
|
||||
result.Account.CreatedAt,
|
||||
result.Account.LastLoginAt));
|
||||
}
|
||||
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Kan gebruiker niet aanmaken.",
|
||||
detail: string.Join(" ", result.Errors));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, ProblemHttpResult>> UpdateUserAsync(
|
||||
Guid userId,
|
||||
[FromBody] UpdateUserRequest request,
|
||||
UpdateWorkspaceUserUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var actorId = CurrentUserId(principal);
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
userId,
|
||||
request.Role,
|
||||
request.Enabled,
|
||||
actorId,
|
||||
correlationId,
|
||||
cancellationToken);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
UpdateWorkspaceUserStatus.Success => TypedResults.Ok(),
|
||||
|
||||
UpdateWorkspaceUserStatus.UserNotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
type: ProblemTypes.NotFound,
|
||||
title: "Gebruiker niet gevonden."),
|
||||
|
||||
UpdateWorkspaceUserStatus.WouldRemoveLastAdministrator => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.LastAdministrator,
|
||||
title: "Kan laatste beheerder niet wijzigen.",
|
||||
detail: "Er moet minstens één ingeschakelde beheerder overblijven."),
|
||||
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Ongeldige aanvraag.",
|
||||
detail: string.Join(" ", result.Errors))
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<IssueRecoveryTokenResponse>, ProblemHttpResult>> IssueRecoveryTokenAsync(
|
||||
Guid userId,
|
||||
IssueRecoveryTokenUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var actorId = CurrentUserId(principal);
|
||||
|
||||
var result = await useCase.ExecuteAsync(userId, actorId, correlationId, cancellationToken);
|
||||
|
||||
if (result.Status == IssueRecoveryTokenStatus.UserNotFound)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
type: ProblemTypes.NotFound,
|
||||
title: "Gebruiker niet gevonden.");
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new IssueRecoveryTokenResponse(result.RawToken!, result.ExpiresAt!.Value));
|
||||
}
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Contracts.Attachments;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class AttachmentsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAttachmentsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/attachments/{attachmentId:guid}/content", GetContentAsync)
|
||||
.WithTags("Attachments")
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.AttachmentsRead);
|
||||
|
||||
app.MapGet("/api/v1/pages/{pageId:guid}/attachments", ListForPageAsync)
|
||||
.WithTags("Attachments")
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.AttachmentsRead);
|
||||
|
||||
app.MapPost("/api/v1/pages/{pageId:guid}/attachments", UploadAsync)
|
||||
.WithTags("Attachments")
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.AttachmentsWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.DisableAntiforgery(); // The double-submit CSRF check is our own
|
||||
// AntiforgeryEndpointFilter (header-based, same as
|
||||
// every other mutation) — ASP.NET Core's built-in
|
||||
// form antiforgery targets a different mechanism
|
||||
// and is not used anywhere in this codebase.
|
||||
|
||||
// Staged upload (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md): the
|
||||
// same real, immediately-addressable attachment as the page-scoped upload
|
||||
// above, just owned by an open page-creation session instead of an existing
|
||||
// page until that session's commit re-points ownership atomically.
|
||||
app.MapPost("/api/v1/pages/staged/{sessionId:guid}/attachments", UploadStagedAsync)
|
||||
.WithTags("Attachments")
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.AttachmentsWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.DisableAntiforgery();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Results<EmptyHttpResult, ProblemHttpResult>> GetContentAsync(
|
||||
Guid attachmentId,
|
||||
GetAttachmentContentUseCase useCase,
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.AttachmentNotFound);
|
||||
}
|
||||
|
||||
var requestor = await ResolveRequestorAsync(workspaceId.Value, memberships, principal, cancellationToken);
|
||||
var result = await useCase.ExecuteAsync(workspaceId.Value, attachmentId, requestor, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.AttachmentNotFound);
|
||||
}
|
||||
|
||||
await using var content = result.Content;
|
||||
|
||||
// Inline only for a known-safe raster image; every other type is a download —
|
||||
// and the filename is always encoded via ContentDispositionHeaderValue, never
|
||||
// raw string interpolation into the header (docs/13-attachments.md: "safe
|
||||
// Content-Disposition", "no user-controlled response headers").
|
||||
var disposition = new ContentDispositionHeaderValue(
|
||||
result.Blob.DeliveryPolicy == AttachmentDeliveryPolicy.InlinePreviewEligible ? "inline" : "attachment")
|
||||
{
|
||||
FileNameStar = result.Reference.OriginalFilename
|
||||
};
|
||||
|
||||
httpContext.Response.ContentType = result.Blob.DetectedMediaType;
|
||||
httpContext.Response.Headers.ContentDisposition = disposition.ToString();
|
||||
httpContext.Response.Headers.XContentTypeOptions = "nosniff";
|
||||
// Private content: cacheable by the browser itself for a short window, never
|
||||
// by a shared/intermediate cache.
|
||||
httpContext.Response.Headers.CacheControl = "private, max-age=300";
|
||||
|
||||
await content.CopyToAsync(httpContext.Response.Body, cancellationToken);
|
||||
|
||||
return TypedResults.Empty;
|
||||
}
|
||||
|
||||
private static async Task<Ok<AttachmentListResponse>> ListForPageAsync(
|
||||
Guid pageId,
|
||||
GetPageAttachmentsUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = await useCase.ExecuteAsync(pageId, cancellationToken);
|
||||
|
||||
IReadOnlyList<AttachmentSummary> items = results
|
||||
.Select(result => new AttachmentSummary(
|
||||
result.Reference.Id,
|
||||
result.Reference.OriginalFilename,
|
||||
result.Blob?.DetectedMediaType ?? "application/octet-stream",
|
||||
result.Blob?.DeliveryPolicy.ToString() ?? "Unknown",
|
||||
result.Blob?.ByteLength ?? 0,
|
||||
result.Reference.Description,
|
||||
result.Reference.AltText,
|
||||
result.Reference.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(new AttachmentListResponse(items));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<AttachmentSummary>, ProblemHttpResult>> UploadAsync(
|
||||
Guid pageId,
|
||||
IFormFile file,
|
||||
IWorkspaceRepository workspaces,
|
||||
UploadAttachmentUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new UploadAttachmentCommand(
|
||||
workspaceId.Value, AttachmentOwner.ForPage(pageId), file.FileName, file.ContentType, stream,
|
||||
CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
return ToUploadResult(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<AttachmentSummary>, ProblemHttpResult>> UploadStagedAsync(
|
||||
Guid sessionId,
|
||||
IFormFile file,
|
||||
IWorkspaceRepository workspaces,
|
||||
UploadAttachmentUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new UploadAttachmentCommand(
|
||||
workspaceId.Value, AttachmentOwner.ForStagingSession(sessionId), file.FileName, file.ContentType, stream,
|
||||
CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
return ToUploadResult(result);
|
||||
}
|
||||
|
||||
private static Results<Ok<AttachmentSummary>, ProblemHttpResult> ToUploadResult(UploadAttachmentResult result)
|
||||
{
|
||||
if (result.Status != UploadAttachmentStatus.Success)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
UploadAttachmentStatus.PageNotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound),
|
||||
UploadAttachmentStatus.StagingSessionNotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.StagingSessionNotFound,
|
||||
detail: "De sessie bestaat niet (meer), is al gepubliceerd/verwijderd, of is verlopen."),
|
||||
UploadAttachmentStatus.TooLarge => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status413PayloadTooLarge, type: ProblemTypes.AttachmentTooLarge, detail: result.Detail),
|
||||
UploadAttachmentStatus.Rejected => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.AttachmentRejected, detail: result.Detail),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: result.Detail)
|
||||
};
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new AttachmentSummary(
|
||||
result.ReferenceId!.Value,
|
||||
result.StoredFilename!,
|
||||
result.DetectedMediaType!,
|
||||
result.DeliveryPolicy!.Value.ToString(),
|
||||
result.ByteLength!.Value,
|
||||
null,
|
||||
null,
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
/// <summary>Distinguishes a human (cookie-authenticated) requestor, whose workspace
|
||||
/// role is resolved live from the database, from a service-account (bearer-token)
|
||||
/// requestor — recognized by <see cref="ServiceTokenAuthenticationHandler.ServiceAccountClaimType"/>,
|
||||
/// since <see cref="ServiceTokenAuthenticationHandler"/> deliberately reuses the
|
||||
/// standard name-identifier claim for the service account's own ID, not a human
|
||||
/// user's ID.</summary>
|
||||
private static async Task<AttachmentRequestor> ResolveRequestorAsync(
|
||||
Guid workspaceId, IWorkspaceMembershipRepository memberships, ClaimsPrincipal principal, CancellationToken cancellationToken)
|
||||
{
|
||||
if (principal.HasClaim(claim => claim.Type == ServiceTokenAuthenticationHandler.ServiceAccountClaimType))
|
||||
{
|
||||
return AttachmentRequestor.ServiceAccount;
|
||||
}
|
||||
|
||||
var userId = CurrentUserId(principal);
|
||||
var role = await memberships.GetRoleAsync(workspaceId, userId, cancellationToken);
|
||||
return new AttachmentRequestor(userId, role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Contracts.Auth;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/auth").WithTags("Auth");
|
||||
|
||||
group.MapGet("/csrf", GetCsrfTokenAsync);
|
||||
group.MapGet("/session", GetSessionAsync);
|
||||
|
||||
group.MapPost("/login", LoginAsync)
|
||||
.RequireRateLimiting(RateLimitPolicies.Login);
|
||||
|
||||
group.MapPost("/logout", LogoutAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/recovery/redeem", RedeemRecoveryAsync)
|
||||
.RequireRateLimiting(RateLimitPolicies.Recovery);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static Ok<CsrfTokenResponse> GetCsrfTokenAsync(HttpContext httpContext, IAntiforgery antiforgery)
|
||||
{
|
||||
var tokens = antiforgery.GetAndStoreTokens(httpContext);
|
||||
return TypedResults.Ok(new CsrfTokenResponse(tokens.RequestToken!, tokens.HeaderName!));
|
||||
}
|
||||
|
||||
private static async Task<Ok<SessionResponse>> GetSessionAsync(
|
||||
GetSessionUseCase useCase,
|
||||
IOptions<IngestionProposalOptions> ingestionOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await useCase.ExecuteAsync(cancellationToken);
|
||||
return TypedResults.Ok(ToSessionResponse(session, ingestionOptions.Value));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<SessionResponse>, ProblemHttpResult>> LoginAsync(
|
||||
[FromBody] LoginRequest request,
|
||||
LoginUseCase loginUseCase,
|
||||
GetSessionUseCase sessionUseCase,
|
||||
IOptions<IngestionProposalOptions> ingestionOptions,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
|
||||
var outcome = await loginUseCase.ExecuteAsync(
|
||||
new LoginCommand(request.Email, request.Password, correlationId),
|
||||
cancellationToken);
|
||||
|
||||
switch (outcome.Status)
|
||||
{
|
||||
case PasswordSignInStatus.Success:
|
||||
var session = await sessionUseCase.ExecuteAsync(cancellationToken);
|
||||
return TypedResults.Ok(ToSessionResponse(session, ingestionOptions.Value));
|
||||
|
||||
case PasswordSignInStatus.LockedOut:
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status429TooManyRequests,
|
||||
type: ProblemTypes.AccountLockedOut,
|
||||
title: "Account tijdelijk vergrendeld.",
|
||||
detail: "Te veel mislukte aanmeldpogingen. Probeer het later opnieuw.");
|
||||
|
||||
// Deliberately identical to the default InvalidCredentials case: a disabled
|
||||
// account must not be distinguishable from a wrong password.
|
||||
case PasswordSignInStatus.AccountDisabled:
|
||||
default:
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status401Unauthorized,
|
||||
type: ProblemTypes.InvalidCredentials,
|
||||
title: "Aanmelden mislukt.",
|
||||
detail: "E-mailadres of wachtwoord is onjuist.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Ok> LogoutAsync(
|
||||
LogoutUseCase useCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
await useCase.ExecuteAsync(correlationId, cancellationToken);
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, ProblemHttpResult>> RedeemRecoveryAsync(
|
||||
[FromBody] RecoveryRedeemRequest request,
|
||||
RedeemRecoveryTokenUseCase useCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
|
||||
var result = await useCase.ExecuteAsync(request.Token, request.NewPassword, correlationId, cancellationToken);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
RedeemRecoveryTokenStatus.Success => TypedResults.Ok(),
|
||||
|
||||
RedeemRecoveryTokenStatus.InvalidOrExpiredToken => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
type: ProblemTypes.InvalidRecoveryToken,
|
||||
title: "Ongeldige of verlopen herstelcode.",
|
||||
detail: "Vraag een nieuwe herstelcode aan bij een beheerder."),
|
||||
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Ongeldig wachtwoord.",
|
||||
detail: string.Join(" ", result.Errors))
|
||||
};
|
||||
}
|
||||
|
||||
private static SessionResponse ToSessionResponse(SessionInfo session, IngestionProposalOptions ingestionOptions) => new(
|
||||
session.Authenticated,
|
||||
session.SetupRequired,
|
||||
session.UserId,
|
||||
session.DisplayName,
|
||||
session.Email,
|
||||
session.WorkspaceId,
|
||||
session.Role?.ToString(),
|
||||
session.Authenticated ? new FeatureFlagsResponse(ingestionOptions.Enabled) : null);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using ItWorx.KnowledgeHub.Application.Operations.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Operations;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Administrator-only, cookie-authenticated backup management (docs/18-backup-restore.md).
|
||||
/// Deliberately create/list only — restore is never reachable through the live
|
||||
/// application's own HTTP surface (see <c>RestoreModeRunner</c> and ADR-016 decision 4).
|
||||
/// </summary>
|
||||
public static class BackupsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapBackupsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/admin/backups")
|
||||
.WithTags("Backups")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
|
||||
group.MapPost("", CreateAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<BackupListResponse>> ListAsync(ListBackupsUseCase useCase, CancellationToken cancellationToken)
|
||||
{
|
||||
var backups = await useCase.ExecuteAsync(cancellationToken);
|
||||
return TypedResults.Ok(new BackupListResponse(backups.Select(ToResponse).ToList()));
|
||||
}
|
||||
|
||||
private static async Task<Ok<BackupSummaryResponse>> CreateAsync(
|
||||
CreateBackupUseCase useCase, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var actorId = Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var summary = await useCase.ExecuteAsync(new CreateBackupCommand(actorId, correlationId), cancellationToken);
|
||||
return TypedResults.Ok(ToResponse(summary));
|
||||
}
|
||||
|
||||
private static BackupSummaryResponse ToResponse(BackupSummary summary) =>
|
||||
new(
|
||||
summary.BackupId, summary.CreatedAt, summary.TotalSizeBytes, summary.TableCount,
|
||||
summary.TotalRowCount, summary.AttachmentCount, summary.SchemaVersion, summary.ProductVersion);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Contracts.Decisions;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class DecisionsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDecisionsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// See PagesEndpoints.cs's identical comment (ADR-015).
|
||||
var group = app.MapGroup("/api/v1/decisions").WithTags("Decisions");
|
||||
|
||||
group.MapGet("", ListAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.DecisionsRead);
|
||||
group.MapGet("/{decisionId:guid}", GetAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.DecisionsRead);
|
||||
|
||||
group.MapPost("", CreateAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.DecisionsWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/{decisionId:guid}/accept", AcceptAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{decisionId:guid}/reject", RejectAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{decisionId:guid}/withdraw", WithdrawAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<DecisionListResponse>> ListAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
IPageRepository pages,
|
||||
ListDecisionsUseCase useCase,
|
||||
[FromQuery] Guid? projectId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Ok(new DecisionListResponse([], null));
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(workspaceId.Value, projectId, status, cursor, limit ?? 50, cancellationToken);
|
||||
|
||||
var items = new List<DecisionSummary>(result.Items.Count);
|
||||
foreach (var decision in result.Items)
|
||||
{
|
||||
var page = await pages.FindAsync(decision.PageId, cancellationToken);
|
||||
items.Add(new DecisionSummary(
|
||||
decision.Id, decision.ProjectId, decision.DecisionNumber, decision.Status.ToString(),
|
||||
page?.TitleValue ?? string.Empty,
|
||||
decision.DecisionDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
decision.Summary, decision.UpdatedAt));
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new DecisionListResponse(items, result.NextCursor));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> GetAsync(
|
||||
Guid decisionId, GetDecisionUseCase useCase, HttpContext httpContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await useCase.ExecuteAsync(decisionId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.DecisionNotFound);
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
|
||||
return TypedResults.Ok(ToDecisionDetail(result));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> CreateAsync(
|
||||
[FromBody] CreateDecisionRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
CreateDecisionUseCase createUseCase,
|
||||
GetDecisionUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await createUseCase.ExecuteAsync(
|
||||
new CreateDecisionCommand(
|
||||
workspaceId.Value, request.ParentPageId, request.ProjectId, request.Title, request.Markdown,
|
||||
request.Summary, request.SupersedesDecisionId, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", result.Errors));
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(result.Value, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
|
||||
return TypedResults.Ok(ToDecisionDetail(detail));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> AcceptAsync(
|
||||
Guid decisionId, AcceptDecisionUseCase useCase, GetDecisionUseCase getUseCase, HttpContext httpContext, CancellationToken cancellationToken) =>
|
||||
await ApplyTransitionAsync(
|
||||
decisionId, httpContext,
|
||||
(etag, actorId, correlationId) => useCase.ExecuteAsync(new AcceptDecisionCommand(decisionId, etag, actorId, correlationId), cancellationToken),
|
||||
getUseCase, cancellationToken);
|
||||
|
||||
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> RejectAsync(
|
||||
Guid decisionId, RejectDecisionUseCase useCase, GetDecisionUseCase getUseCase, HttpContext httpContext, CancellationToken cancellationToken) =>
|
||||
await ApplyTransitionAsync(
|
||||
decisionId, httpContext,
|
||||
(etag, actorId, correlationId) => useCase.ExecuteAsync(new RejectDecisionCommand(decisionId, etag, actorId, correlationId), cancellationToken),
|
||||
getUseCase, cancellationToken);
|
||||
|
||||
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> WithdrawAsync(
|
||||
Guid decisionId, WithdrawDecisionUseCase useCase, GetDecisionUseCase getUseCase, HttpContext httpContext, CancellationToken cancellationToken) =>
|
||||
await ApplyTransitionAsync(
|
||||
decisionId, httpContext,
|
||||
(etag, actorId, correlationId) => useCase.ExecuteAsync(new WithdrawDecisionCommand(decisionId, etag, actorId, correlationId), cancellationToken),
|
||||
getUseCase, cancellationToken);
|
||||
|
||||
private static async Task<Results<Ok<DecisionDetail>, ProblemHttpResult>> ApplyTransitionAsync(
|
||||
Guid decisionId,
|
||||
HttpContext httpContext,
|
||||
Func<string, Guid, string?, Task<OperationResult>> transition,
|
||||
GetDecisionUseCase getUseCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await transition(ifMatch, CurrentUserId(httpContext.User), correlationId);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
OperationStatus.NotFound => TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.DecisionNotFound),
|
||||
OperationStatus.ConcurrencyConflict => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
|
||||
OperationStatus.InvalidTransition => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.DecisionInvalidTransition,
|
||||
title: "Ongeldige statusovergang.", detail: string.Join(" ", result.Errors)),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", result.Errors))
|
||||
};
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(decisionId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
|
||||
return TypedResults.Ok(ToDecisionDetail(detail));
|
||||
}
|
||||
|
||||
private static DecisionDetail ToDecisionDetail(DecisionDetailResult result) =>
|
||||
new(
|
||||
result.Decision.Id, result.Decision.WorkspaceId, result.Decision.ProjectId, result.Decision.PageId,
|
||||
result.Decision.DecisionNumber, result.Decision.Status.ToString(),
|
||||
result.Decision.DecisionDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
result.Page.Page.TitleValue, result.Page.Page.Markdown, result.Page.RenderedHtml, result.Decision.Summary,
|
||||
result.Supersedes is null ? null : new DecisionLineageEntry(
|
||||
result.Supersedes.Id, result.Supersedes.DecisionNumber, result.Supersedes.Status.ToString(), result.Supersedes.Summary),
|
||||
result.SupersededBy is null ? null : new DecisionLineageEntry(
|
||||
result.SupersededBy.Id, result.SupersededBy.DecisionNumber, result.SupersededBy.Status.ToString(), result.SupersededBy.Summary),
|
||||
result.Decision.CreatedAt, result.Decision.UpdatedAt, result.ETag);
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>Draft autosave — private per user, never silently overwrites a newer
|
||||
/// published revision (docs/27-acceptance-matrix.md, M2-A04).</summary>
|
||||
public static class DraftsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDraftsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/drafts")
|
||||
.WithTags("Drafts")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor);
|
||||
|
||||
group.MapGet("/{draftKey}", GetDraftAsync);
|
||||
|
||||
group.MapPut("/{draftKey}", SaveDraftAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapDelete("/{draftKey}", DiscardDraftAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<DraftResponse>, ProblemHttpResult>> GetDraftAsync(
|
||||
string draftKey,
|
||||
IWorkspaceRepository workspaces,
|
||||
GetDraftUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var draft = await useCase.ExecuteAsync(workspaceId.Value, CurrentUserId(principal), draftKey, cancellationToken);
|
||||
if (draft is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new DraftResponse(
|
||||
draft.DraftKey, draft.PageId, draft.BaseRevisionNumber, draft.Title, draft.Markdown, draft.UpdatedAt, draft.ExpiresAt,
|
||||
BaseRevisionIsStale: false, draft.Tags, draft.DocumentType, draft.DocumentStatus, draft.ChangeSummary));
|
||||
}
|
||||
|
||||
private static async Task<Ok<DraftResponse>> SaveDraftAsync(
|
||||
string draftKey,
|
||||
[FromBody] SaveDraftRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
SaveDraftUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = (await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken))!.Value;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new SaveDraftCommand(
|
||||
workspaceId,
|
||||
CurrentUserId(principal),
|
||||
draftKey,
|
||||
request.PageId,
|
||||
request.BaseRevisionNumber,
|
||||
request.Title,
|
||||
request.Markdown,
|
||||
request.Tags,
|
||||
request.DocumentType,
|
||||
request.DocumentStatus,
|
||||
request.ChangeSummary),
|
||||
cancellationToken);
|
||||
|
||||
var draft = result.Draft;
|
||||
return TypedResults.Ok(new DraftResponse(
|
||||
draft.DraftKey,
|
||||
draft.PageId,
|
||||
draft.BaseRevisionNumber,
|
||||
draft.Title,
|
||||
draft.Markdown,
|
||||
draft.UpdatedAt,
|
||||
draft.ExpiresAt,
|
||||
result.BaseRevisionIsStale,
|
||||
draft.Tags,
|
||||
draft.DocumentType,
|
||||
draft.DocumentStatus,
|
||||
draft.ChangeSummary));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, ProblemHttpResult>> DiscardDraftAsync(
|
||||
string draftKey,
|
||||
IWorkspaceRepository workspaces,
|
||||
DiscardDraftUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var removed = await useCase.ExecuteAsync(workspaceId.Value, CurrentUserId(principal), draftKey, cancellationToken);
|
||||
return removed
|
||||
? TypedResults.Ok()
|
||||
: TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Migration.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Migration;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// LeafWiki import: Administrator-level, since preflight reads an operator-mounted
|
||||
/// server-side directory (docs/17-leafwiki-migration.md) — but, like every other
|
||||
/// content-mutating area of this API (Pages, Runbooks, Infrastructure), reachable by
|
||||
/// either a cookie-authenticated Administrator or a scoped service-account bearer
|
||||
/// token carrying the documented imports:read/imports:execute scopes (previously
|
||||
/// defined in Scopes.cs but never actually consumed anywhere — found while running a
|
||||
/// real migration). TryResolveSourcePath's own directory-boundary check (the
|
||||
/// candidate path must resolve inside the configured imports root) already enforces
|
||||
/// the real safety property regardless of which auth mechanism reached it, so
|
||||
/// widening this does not weaken it.
|
||||
/// </summary>
|
||||
public static class ImportsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapImportsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/admin/imports").WithTags("Imports");
|
||||
|
||||
group.MapPost("/preflight", PreflightAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapGet("/{jobId:guid}", GetJobAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsRead);
|
||||
|
||||
group.MapPost("/{jobId:guid}/execute", ExecuteAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{jobId:guid}/rollback", RollbackAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.ImportsExecute)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ImportPreflightResponse>, ProblemHttpResult>> PreflightAsync(
|
||||
[FromBody] PreflightImportRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
IConfiguration configuration,
|
||||
PreflightImportUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
if (!TryResolveSourcePath(configuration, request.SourceRelativePath, out var sourceRootPath))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.ImportSourceInvalid,
|
||||
title: "Ongeldig brondirectory-pad.",
|
||||
detail: "Het pad moet binnen de geconfigureerde imports-map liggen.");
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new PreflightImportCommand(workspaceId.Value, sourceRootPath, request.IdempotencyKey, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status == PreflightImportStatus.NoApplicableAdapter)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.ImportNoApplicableAdapter,
|
||||
title: "Geen adapter kan deze bron interpreteren.",
|
||||
detail: string.Join(" ", result.Blockers));
|
||||
}
|
||||
|
||||
var job = result.Job!;
|
||||
IReadOnlyList<ImportPlanItemResponse> items = result.Plan?.Items
|
||||
.Select(item => new ImportPlanItemResponse(
|
||||
item.SourceIdentity, item.Kind.ToString(), item.ProposedTitle, item.ProposedParentSourceIdentity,
|
||||
item.Conflict.ToString(), item.ConflictDetail))
|
||||
.ToList() ?? [];
|
||||
|
||||
return TypedResults.Ok(new ImportPreflightResponse(
|
||||
result.Status.ToString(), job.Id, job.AdapterName, job.DiscoveredCount, job.PlannedCount, items, result.Warnings, result.Blockers));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ImportJobSummaryResponse>, ProblemHttpResult>> GetJobAsync(
|
||||
Guid jobId, GetImportJobUseCase useCase, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await useCase.ExecuteAsync(jobId, cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new ImportJobSummaryResponse(
|
||||
job.Id, job.AdapterName, job.Status.ToString(), job.DiscoveredCount, job.PlannedCount,
|
||||
job.ImportedCount, job.SkippedCount, job.FailedCount, job.CreatedAt, job.StartedAt, job.CompletedAt));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExecuteImportResponse>, ProblemHttpResult>> ExecuteAsync(
|
||||
Guid jobId,
|
||||
IConfiguration configuration,
|
||||
GetImportJobUseCase getJob,
|
||||
ExecuteImportUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await getJob.ExecuteAsync(jobId, cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound);
|
||||
}
|
||||
|
||||
// The job itself does not persist which relative path it was preflighted
|
||||
// against beyond the fingerprint hash; the caller must supply the same
|
||||
// relative path again so it can be re-resolved and re-verified against that
|
||||
// recorded fingerprint (never trusted blindly).
|
||||
var relativePath = httpContext.Request.Query["sourceRelativePath"].ToString();
|
||||
if (!TryResolveSourcePath(configuration, relativePath, out var sourceRootPath))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.ImportSourceInvalid);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new ExecuteImportCommand(jobId, sourceRootPath, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
if (result.Status != ExecuteImportStatus.Success)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
ExecuteImportStatus.JobNotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound),
|
||||
ExecuteImportStatus.SourceChanged => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.ImportSourceChanged,
|
||||
title: "Bron is gewijzigd sinds de preflight.",
|
||||
detail: "Voer preflight opnieuw uit."),
|
||||
_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.Validation)
|
||||
};
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new ExecuteImportResponse(
|
||||
result.Status.ToString(), result.Imported, result.Skipped, result.Failed, result.SourceUnchangedAfterExecution, result.Diagnostics));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RollbackImportResponse>, ProblemHttpResult>> RollbackAsync(
|
||||
Guid jobId,
|
||||
RollbackImportUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(new RollbackImportCommand(jobId, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
if (result.Status != RollbackImportStatus.Success)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
RollbackImportStatus.JobNotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ImportJobNotFound),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Er is niets om terug te draaien voor deze importtaak.")
|
||||
};
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new RollbackImportResponse(
|
||||
result.Status.ToString(), result.RemovedPages, result.PreservedPages, result.RemovedAttachments));
|
||||
}
|
||||
|
||||
private static bool TryResolveSourcePath(IConfiguration configuration, string relativePath, out string resolvedPath)
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
try
|
||||
{
|
||||
var configuredImportsRoot = Path.GetFullPath(
|
||||
configuration["KnowledgeHub:Paths:Imports"] ?? Path.Combine(AppContext.BaseDirectory, "imports-data"));
|
||||
if (!TryResolveCanonicalDirectory(configuredImportsRoot, out var importsRoot))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
resolvedPath = importsRoot;
|
||||
return true;
|
||||
}
|
||||
|
||||
var candidate = Path.GetFullPath(Path.Combine(configuredImportsRoot, relativePath));
|
||||
if (!FileSystemPathBoundary.Contains(configuredImportsRoot, candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var relativeCandidate = Path.GetRelativePath(configuredImportsRoot, candidate);
|
||||
if (!TryResolveUnlinkedDirectory(importsRoot, relativeCandidate, out resolvedPath))
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException)
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveCanonicalDirectory(string path, out string canonicalPath)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var volumeRoot = Path.GetPathRoot(fullPath);
|
||||
if (string.IsNullOrEmpty(volumeRoot) || !Directory.Exists(volumeRoot))
|
||||
{
|
||||
canonicalPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = volumeRoot;
|
||||
foreach (var segment in fullPath[volumeRoot.Length..].Split(
|
||||
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||
StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var next = Path.Combine(current, segment);
|
||||
var info = new DirectoryInfo(next);
|
||||
if (!info.Exists)
|
||||
{
|
||||
canonicalPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((info.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
var target = info.ResolveLinkTarget(returnFinalTarget: true);
|
||||
if (target is null || !Directory.Exists(target.FullName))
|
||||
{
|
||||
canonicalPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
current = Path.GetFullPath(target.FullName);
|
||||
}
|
||||
else
|
||||
{
|
||||
current = Path.GetFullPath(next);
|
||||
}
|
||||
}
|
||||
|
||||
canonicalPath = current;
|
||||
return Directory.Exists(canonicalPath);
|
||||
}
|
||||
|
||||
private static bool TryResolveUnlinkedDirectory(string root, string relativePath, out string resolvedPath)
|
||||
{
|
||||
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
|
||||
if (!FileSystemPathBoundary.Contains(root, candidate) || !Directory.Exists(candidate))
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var relativeCandidate = Path.GetRelativePath(root, candidate);
|
||||
var current = root;
|
||||
if (relativeCandidate != ".")
|
||||
{
|
||||
foreach (var segment in relativeCandidate.Split(
|
||||
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||
StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
var info = new DirectoryInfo(current);
|
||||
if (!info.Exists || (info.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolvedPath = Path.GetFullPath(current);
|
||||
return FileSystemPathBoundary.Contains(root, resolvedPath);
|
||||
}
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class InfrastructureEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapInfrastructureEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/infrastructure").WithTags("Infrastructure");
|
||||
|
||||
group.MapGet("/overview", GetOverviewAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
|
||||
group.MapGet("/services", ListServicesAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
|
||||
group.MapGet("/storage", ListStorageAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
|
||||
group.MapGet("/services/{serviceId:guid}", GetServiceAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
|
||||
group.MapGet("/changes", ListChangesAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
|
||||
group.MapGet("/overrides/catalog", GetManualOverrideCatalogAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.InfrastructureRead);
|
||||
|
||||
group.MapPut("/services/{serviceId:guid}/editorial-profile", UpdateEditorialProfileAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/services/{serviceId:guid}/overrides", SetOverrideAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
// Generic sibling of the route above (V2-R5, docs/adr/ADR-029-v2-r5-final-
|
||||
// hardening.md): corrects a fact for any InfrastructureObjectKind (Host,
|
||||
// Network, Volume, Database, Endpoint, Component, Deployment, Backup), not
|
||||
// only a Service reached through its own nested route.
|
||||
group.MapPost("/overrides", SetGenericOverrideAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/services/{serviceId:guid}/review", ReviewServiceAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapDelete("/overrides/{overrideId:guid}", RemoveOverrideAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.InfrastructureWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/inventory/snapshots", SynchronizeAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
group.MapPost("/inventory/curated-package", ImportCuratedPackageAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Administrator, Scopes.InfrastructureWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOverviewAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
GetInfrastructureOverviewUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Ok(new InfrastructureOverviewResponse(null, [], 0, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
var overview = await useCase.ExecuteAsync(workspaceId.Value, cancellationToken);
|
||||
return Results.Ok(new InfrastructureOverviewResponse(
|
||||
overview.LatestSnapshot is null ? null : ToSnapshot(overview.LatestSnapshot),
|
||||
overview.Hosts.Select(ToHost).ToArray(), overview.ServiceCount, overview.ProposedServiceCount,
|
||||
overview.MissingServiceCount, overview.UnhealthyServiceCount, overview.UnprotectedServiceCount));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListServicesAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
ListInfrastructureServiceSummariesUseCase useCase,
|
||||
[FromQuery] bool? includeHidden,
|
||||
[FromQuery] Guid? projectId,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Ok(new InfrastructureServiceListResponse([], null, 0));
|
||||
}
|
||||
|
||||
var page = await useCase.ExecuteAsync(
|
||||
workspaceId.Value, includeHidden ?? false, projectId, search, status, cursor, limit ?? 50, cancellationToken);
|
||||
return Results.Ok(
|
||||
new InfrastructureServiceListResponse(
|
||||
page.Items.Select(ToSummary).ToArray(), page.NextCursor, page.TotalCount));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListStorageAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
ListInfrastructureVolumesUseCase useCase,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Ok(new InfrastructureStorageListResponse([], null));
|
||||
}
|
||||
|
||||
var page = await useCase.ExecuteAsync(workspaceId.Value, search, cursor, limit ?? 50, cancellationToken);
|
||||
var items = page.Items.Select(row => new InfrastructureStorageRow(
|
||||
row.Volume.Id, row.Service.Id, row.ServiceDisplayName, row.Volume.Name, row.Volume.MountPath,
|
||||
row.Volume.StorageClass, row.Volume.ReadOnly, row.Volume.SizeBytes, Provenance(row.Volume))).ToArray();
|
||||
return Results.Ok(new InfrastructureStorageListResponse(items, page.NextCursor));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetServiceAsync(
|
||||
Guid serviceId,
|
||||
IWorkspaceRepository workspaces,
|
||||
GetInfrastructureServiceUseCase useCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
var graph = workspaceId is null
|
||||
? null
|
||||
: await useCase.ExecuteAsync(workspaceId.Value, serviceId, cancellationToken);
|
||||
if (graph is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(graph.EditorialETag);
|
||||
return Results.Ok(ToDetail(graph));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListChangesAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
ListInfrastructureChangesUseCase useCase,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Ok(Array.Empty<InventoryChangeResponse>());
|
||||
}
|
||||
|
||||
var changes = await useCase.ExecuteAsync(workspaceId.Value, limit ?? 100, cancellationToken);
|
||||
return Results.Ok(changes.Select(change => new InventoryChangeResponse(
|
||||
change.Id, change.SnapshotId, change.TargetKind.ToString(), change.TargetId,
|
||||
change.SourceKey, change.ChangeKind.ToString(), change.ChangedFields, change.RecordedAt)).ToArray());
|
||||
}
|
||||
|
||||
private static IResult GetManualOverrideCatalogAsync([FromQuery] string? targetKind)
|
||||
{
|
||||
if (!Enum.TryParse<InfrastructureObjectKind>(targetKind ?? "Service", true, out var kind) || !Enum.IsDefined(kind))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
|
||||
detail: "Onbekend objecttype.");
|
||||
}
|
||||
|
||||
var fields = ItWorx.KnowledgeHub.Application.Infrastructure.Validation.ManualOverrideCatalog.FieldsFor(kind)
|
||||
.Select(field => new ManualOverrideFieldCatalogEntry(
|
||||
field.FieldName, field.DisplayLabel, field.ValueKind.ToString(), field.MaxStringLength, field.AllowedEnumValues))
|
||||
.ToArray();
|
||||
return Results.Ok(new ManualOverrideFieldCatalogResponse(kind.ToString(), fields));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateEditorialProfileAsync(
|
||||
Guid serviceId,
|
||||
[FromBody] UpdateEditorialProfileRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
UpdateInfrastructureEditorialProfileUseCase updateUseCase,
|
||||
GetInfrastructureServiceUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
var expectedETag = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (workspaceId is null || expectedETag is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: expectedETag is null ? StatusCodes.Status428PreconditionRequired : StatusCodes.Status404NotFound,
|
||||
type: expectedETag is null ? ProblemTypes.Validation : ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await updateUseCase.ExecuteAsync(
|
||||
new UpdateInfrastructureEditorialProfileCommand(
|
||||
workspaceId.Value, InfrastructureObjectKind.Service, serviceId,
|
||||
request.DisplayName, request.Summary, request.Category, request.Purpose, request.OwnerId, request.ProjectId,
|
||||
request.RepositoryUrl, request.LiveUrl, request.BackupStatus, request.MonitoringUrl,
|
||||
request.MaintenanceNotes, request.KnownRisks, request.UpdateProcedure, request.RollbackProcedure,
|
||||
request.Tags, request.IsFavorite, request.IsHidden, request.IsArchived, expectedETag,
|
||||
CurrentUserId(principal), CorrelationId(httpContext)),
|
||||
cancellationToken);
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var graph = await getUseCase.ExecuteAsync(workspaceId.Value, serviceId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(graph!.EditorialETag);
|
||||
return Results.Ok(ToDetail(graph));
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetOverrideAsync(
|
||||
Guid serviceId,
|
||||
[FromBody] SetManualOverrideRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
SetManualOverrideUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new SetManualOverrideCommand(
|
||||
workspaceId.Value, InfrastructureObjectKind.Service, serviceId, request.FieldName,
|
||||
request.Value.GetRawText(), request.Reason, CurrentUserId(principal), CorrelationId(httpContext)),
|
||||
cancellationToken);
|
||||
return result.Status == OperationStatus.Success
|
||||
? Results.Ok(new { id = result.Value })
|
||||
: ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetGenericOverrideAsync(
|
||||
[FromBody] SetInfrastructureOverrideRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
SetManualOverrideUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<InfrastructureObjectKind>(request.TargetKind, true, out var targetKind) || !Enum.IsDefined(targetKind))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
|
||||
detail: "Onbekend objecttype.");
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new SetManualOverrideCommand(
|
||||
workspaceId.Value, targetKind, request.TargetId, request.FieldName,
|
||||
request.Value.GetRawText(), request.Reason, CurrentUserId(principal), CorrelationId(httpContext)),
|
||||
cancellationToken);
|
||||
return result.Status == OperationStatus.Success
|
||||
? Results.Ok(new { id = result.Value })
|
||||
: ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveOverrideAsync(
|
||||
Guid overrideId,
|
||||
IWorkspaceRepository workspaces,
|
||||
RemoveManualOverrideUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
workspaceId.Value, overrideId, CurrentUserId(principal), CorrelationId(httpContext), cancellationToken);
|
||||
return result.Status == OperationStatus.Success ? Results.NoContent() : ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReviewServiceAsync(
|
||||
Guid serviceId,
|
||||
[FromBody] ReviewInfrastructureServiceRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
ReviewInfrastructureServiceUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<InfrastructureServiceReviewDecision>(request.Decision, true, out var decision)
|
||||
|| !Enum.IsDefined(decision))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
detail: "Beslissing moet 'Accept' of 'Reject' zijn.");
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new ReviewInfrastructureServiceCommand(
|
||||
workspaceId.Value, serviceId, decision, request.Reason,
|
||||
CurrentUserId(principal), CorrelationId(httpContext)),
|
||||
cancellationToken);
|
||||
return result.Status == OperationStatus.Success
|
||||
? Results.Ok(new ReviewInfrastructureServiceResponse(serviceId, result.Value.ToString()))
|
||||
: ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SynchronizeAsync(
|
||||
[FromBody] SynchronizeInventoryRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
SynchronizeInfrastructureInventoryUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
InventorySnapshotInput snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = ToSnapshotInput(request);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: exception.Message);
|
||||
}
|
||||
|
||||
var actorId = CurrentActorId(principal);
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new SynchronizeInfrastructureInventoryCommand(
|
||||
workspaceId.Value, snapshot, actorId,
|
||||
principal.Identity?.AuthenticationType == ServiceTokenAuthenticationDefaults.Scheme
|
||||
? AuditActorKind.ServiceAccount : AuditActorKind.User,
|
||||
principal.Identity?.AuthenticationType == ServiceTokenAuthenticationDefaults.Scheme ? "service-token" : "web",
|
||||
CorrelationId(httpContext)),
|
||||
cancellationToken);
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var sync = result.Value!;
|
||||
return Results.Ok(new InventorySyncResponse(
|
||||
sync.SnapshotId, sync.AlreadyImported, sync.AddedServices, sync.UpdatedServices, sync.MissingServices,
|
||||
sync.PreservedEditorialProfiles, sync.PreservedOverrides));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ImportCuratedPackageAsync(
|
||||
[FromBody] ImportCuratedInventoryRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
ImportCuratedInventoryPackageUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
InventorySnapshotInput snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = ToSnapshotInput(request.Inventory);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
detail: exception.Message);
|
||||
}
|
||||
|
||||
var profiles = (request.Services ?? []).Select(profile => new CuratedServiceProfileInput(
|
||||
profile.SourceKey, profile.DisplayName, profile.Summary, profile.Category, profile.Purpose,
|
||||
profile.RepositoryUrl, profile.LiveUrl, profile.Tags ?? [], profile.Reviewed, profile.Archived)).ToArray();
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new ImportCuratedInventoryPackageCommand(
|
||||
workspaceId.Value, snapshot, profiles, CurrentUserId(principal), CorrelationId(httpContext)),
|
||||
cancellationToken);
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var imported = result.Value!;
|
||||
var sync = imported.Inventory;
|
||||
return Results.Ok(new ImportCuratedInventoryResponse(
|
||||
new InventorySyncResponse(
|
||||
sync.SnapshotId, sync.AlreadyImported, sync.AddedServices, sync.UpdatedServices,
|
||||
sync.MissingServices, sync.PreservedEditorialProfiles, sync.PreservedOverrides),
|
||||
imported.CreatedEditorialProfiles, imported.ConfirmedServices, imported.ArchivedServices));
|
||||
}
|
||||
|
||||
private static InventorySnapshotInput ToSnapshotInput(SynchronizeInventoryRequest request) =>
|
||||
new(
|
||||
request.SourceKey, request.SourceHash, request.CapturedAt,
|
||||
(request.Hosts ?? []).Select(host => new InventoryHostInput(
|
||||
host.SourceKey, host.Hostname, host.OperatingSystem, host.KernelVersion,
|
||||
host.Architecture, host.LogicalCpuCount, host.TotalMemoryBytes)).ToArray(),
|
||||
(request.Services ?? []).Select(ToServiceInput).ToArray());
|
||||
|
||||
private static InventoryServiceInput ToServiceInput(InventoryServiceRequest service) =>
|
||||
new(
|
||||
service.SourceKey, service.HostSourceKey, service.DetectedName, service.RuntimeKind, service.RuntimeStatus,
|
||||
ParseEnum<HealthState>(service.Health, "health"), service.ImageReference, service.ImageDigest, service.RuntimeUser,
|
||||
service.ReadOnlyRootFilesystem, service.NoNewPrivileges, service.Privileged,
|
||||
ParseEnum<FactConfidence>(service.Confidence, "confidence"),
|
||||
(service.Components ?? []).Select(item => new InventoryComponentInput(
|
||||
item.SourceKey, item.Name, item.Kind, item.RuntimeId, item.Status, item.ImageReference,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "component confidence"))).ToArray(),
|
||||
(service.Endpoints ?? []).Select(item => new InventoryEndpointInput(
|
||||
item.SourceKey, item.Scheme, item.Host, item.PublishedPort, item.TargetPort, item.Url, item.IsPublic,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "endpoint confidence"))).ToArray(),
|
||||
(service.Networks ?? []).Select(item => new InventoryNetworkInput(
|
||||
item.SourceKey, item.Name, item.Driver, item.Scope, item.Internal,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "network confidence"))).ToArray(),
|
||||
(service.Volumes ?? []).Select(item => new InventoryVolumeInput(
|
||||
item.SourceKey, item.Name, item.MountPath, item.StorageClass, item.ReadOnly, item.SizeBytes,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "volume confidence"))).ToArray(),
|
||||
(service.Databases ?? []).Select(item => new InventoryDatabaseInput(
|
||||
item.SourceKey, item.Name, item.Engine, item.Version, item.Host, item.Port, item.HostPortPublished,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "database confidence"))).ToArray(),
|
||||
(service.Dependencies ?? []).Select(item => new InventoryDependencyInput(
|
||||
item.SourceKey, item.DependsOnSourceKey, item.Kind,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "dependency confidence"))).ToArray(),
|
||||
(service.HealthObservations ?? []).Select(item => new InventoryHealthInput(
|
||||
item.SourceKey, ParseEnum<HealthState>(item.State, "health state"), item.CheckKind,
|
||||
item.ResponseTimeMilliseconds, ParseEnum<FactConfidence>(item.Confidence, "health confidence"))).ToArray(),
|
||||
(service.Deployments ?? []).Select(item => new InventoryDeploymentInput(
|
||||
item.SourceKey, item.RepositoryUrl, item.Commit, item.ImageReference, item.ImageDigest, item.DeployedAt,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "deployment confidence"))).ToArray(),
|
||||
(service.Backups ?? []).Select(item => new InventoryBackupInput(
|
||||
item.SourceKey, item.Method, item.Schedule, item.LastSuccessfulAt, item.RestoreTested,
|
||||
ParseEnum<FactConfidence>(item.Confidence, "backup confidence"))).ToArray());
|
||||
|
||||
private static T ParseEnum<T>(string value, string field) where T : struct, Enum
|
||||
{
|
||||
if (!Enum.TryParse<T>(value, true, out var parsed) || !Enum.IsDefined(parsed))
|
||||
{
|
||||
throw new ArgumentException($"Ongeldige waarde voor {field}.", field);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static InventorySnapshotSummary ToSnapshot(InventorySnapshot snapshot) =>
|
||||
new(snapshot.Id, snapshot.SourceKey, snapshot.SourceHash, snapshot.CapturedAt, snapshot.ImportedAt, snapshot.HostCount, snapshot.ServiceCount);
|
||||
|
||||
private static InfrastructureHostSummary ToHost(InfrastructureHost host) =>
|
||||
new(host.Id, host.SourceKey, host.Hostname, host.OperatingSystem, host.KernelVersion, host.Architecture,
|
||||
host.LogicalCpuCount, host.TotalMemoryBytes, host.Presence.ToString(), host.LastSeenAt);
|
||||
|
||||
private static InfrastructureServiceSummary ToSummary(InfrastructureServiceGraph graph) =>
|
||||
ToSummary(
|
||||
graph.Service, graph.EditorialProfile, graph.Components.Count, graph.Endpoints.Count,
|
||||
graph.Volumes.Count, graph.Databases.Count);
|
||||
|
||||
private static InfrastructureServiceSummary ToSummary(InfrastructureServiceSummaryRow row) =>
|
||||
ToSummary(row.Service, row.EditorialProfile, row.ProjectName, row.PrimaryEndpointUrl, row.ComponentCount, row.EndpointCount, row.VolumeCount, row.DatabaseCount);
|
||||
|
||||
private static InfrastructureServiceSummary ToSummary(
|
||||
InfrastructureService service, EditorialProfile? profile,
|
||||
int componentCount, int endpointCount, int volumeCount, int databaseCount) =>
|
||||
ToSummary(service, profile, null, null, componentCount, endpointCount, volumeCount, databaseCount);
|
||||
|
||||
private static InfrastructureServiceSummary ToSummary(
|
||||
InfrastructureService service, EditorialProfile? profile, string? projectName, string? primaryEndpointUrl,
|
||||
int componentCount, int endpointCount, int volumeCount, int databaseCount) =>
|
||||
new(
|
||||
service.Id, service.SourceKey,
|
||||
string.IsNullOrWhiteSpace(profile?.DisplayName) ? service.DetectedName : profile.DisplayName,
|
||||
service.DetectedName, profile?.Category ?? string.Empty, profile?.Summary ?? string.Empty,
|
||||
service.RuntimeKind, service.RuntimeStatus, service.Health.ToString(), service.Presence.ToString(),
|
||||
service.Confidence.ToString(), service.ImageReference, profile?.ProjectId, projectName,
|
||||
profile?.BackupStatus ?? string.Empty, primaryEndpointUrl, profile?.IsFavorite ?? false,
|
||||
profile?.IsHidden ?? false, componentCount, endpointCount, volumeCount, databaseCount, service.LastSeenAt);
|
||||
|
||||
private static InfrastructureServiceDetail ToDetail(InfrastructureServiceGraph graph) =>
|
||||
new(
|
||||
ToSummary(graph), graph.EditorialProfile is null ? null : ToEditorial(graph.EditorialProfile, graph.EditorialETag),
|
||||
graph.EditorialETag, graph.Overrides.Select(ToOverride).ToArray(),
|
||||
graph.Components.Select(row => new ServiceComponentResponse(
|
||||
row.Id, row.Name, row.Kind, row.RuntimeId, row.Status, row.ImageReference, Provenance(row))).ToArray(),
|
||||
graph.Endpoints.Select(row => new InfrastructureEndpointResponse(
|
||||
row.Id, row.Scheme, row.Host, row.PublishedPort, row.TargetPort, row.Url, row.IsPublic, Provenance(row))).ToArray(),
|
||||
graph.Networks.Select(row => new InfrastructureNetworkResponse(
|
||||
row.Id, row.Name, row.Driver, row.Scope, row.Internal, Provenance(row))).ToArray(),
|
||||
graph.Volumes.Select(row => new InfrastructureVolumeResponse(
|
||||
row.Id, row.Name, row.MountPath, row.StorageClass, row.ReadOnly, row.SizeBytes, Provenance(row))).ToArray(),
|
||||
graph.Databases.Select(row => new InfrastructureDatabaseResponse(
|
||||
row.Id, row.Name, row.Engine, row.Version, row.Host, row.Port, row.HostPortPublished, Provenance(row))).ToArray(),
|
||||
graph.Dependencies.Select(row => new ServiceDependencyResponse(
|
||||
row.Id, row.DependsOnServiceId, row.DependsOnSourceKey, row.Kind, Provenance(row))).ToArray(),
|
||||
graph.HealthObservations.Select(row => new HealthObservationResponse(
|
||||
row.Id, row.State.ToString(), row.CheckKind, row.ResponseTimeMilliseconds, Provenance(row))).ToArray(),
|
||||
graph.Deployments.Select(row => new DeploymentReferenceResponse(
|
||||
row.Id, row.RepositoryUrl, row.Commit, row.ImageReference, row.ImageDigest, row.DeployedAt, Provenance(row))).ToArray(),
|
||||
graph.Backups.Select(row => new BackupCoverageResponse(
|
||||
row.Id, row.Method, row.Schedule, row.LastSuccessfulAt, row.RestoreTested, Provenance(row))).ToArray());
|
||||
|
||||
private static EditorialProfileResponse ToEditorial(EditorialProfile profile, string etag) =>
|
||||
new(profile.Id, profile.DisplayName, profile.Summary, profile.Category, profile.Purpose, profile.OwnerId, profile.ProjectId,
|
||||
profile.RepositoryUrl, profile.LiveUrl, profile.BackupStatus, profile.MonitoringUrl, profile.MaintenanceNotes,
|
||||
profile.KnownRisks, profile.UpdateProcedure, profile.RollbackProcedure, profile.Tags, profile.IsFavorite,
|
||||
profile.IsHidden, profile.IsArchived, profile.UpdatedAt, etag);
|
||||
|
||||
private static ManualOverrideResponse ToOverride(ManualOverride manualOverride) =>
|
||||
new(manualOverride.Id, manualOverride.FieldName, JsonSerializer.Deserialize<JsonElement>(manualOverride.ValueJson),
|
||||
manualOverride.Reason, manualOverride.CreatedBy, manualOverride.UpdatedAt);
|
||||
|
||||
private static FactProvenance Provenance(ServiceFactEntity fact) =>
|
||||
new(fact.SourceKey, fact.Confidence.ToString(), fact.LastSnapshotId, fact.ObservedAt);
|
||||
|
||||
private static IResult ToProblem(OperationStatus status, IReadOnlyList<string> errors) => status switch
|
||||
{
|
||||
OperationStatus.NotFound => Results.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound),
|
||||
OperationStatus.ConcurrencyConflict => Results.Problem(
|
||||
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict),
|
||||
_ => Results.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
|
||||
detail: string.Join(" ", errors))
|
||||
};
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
CurrentActorId(principal) ?? throw new InvalidOperationException("Authenticated user has no valid subject identifier.");
|
||||
|
||||
private static Guid? CurrentActorId(ClaimsPrincipal principal) =>
|
||||
Guid.TryParse(principal.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||
|
||||
private static string? CorrelationId(HttpContext context) =>
|
||||
context.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Ingestion;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class IngestionProposalsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapIngestionProposalsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/ingestion-proposals").WithTags("Ingestion proposals");
|
||||
group.MapPost("", CreateAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
group.MapPost("/{proposalId:guid}/sources", UploadSourceAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
|
||||
.RequireRateLimiting(RateLimitPolicies.SourceUpload)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>().DisableAntiforgery();
|
||||
group.MapPost("/{proposalId:guid}/submit", SubmitManualAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapGet("", ListAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview);
|
||||
// Detail/PATCH are intentionally broader than the review queue for trusted
|
||||
// intake submitters; private lifecycle states are still restricted to their
|
||||
// submitter and workspace administrators.
|
||||
group.MapGet("/{proposalId:guid}", GetAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite);
|
||||
group.MapPatch("/{proposalId:guid}", PatchAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/{proposalId:guid}/request-changes", RequestChangesAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/{proposalId:guid}/approve", ApproveAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/{proposalId:guid}/reject", RejectAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/{proposalId:guid}/retry", RetryAsync).RequireAuthorization(AuthorizationPolicies.IngestionWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
group.MapPost("/{proposalId:guid}/publish", PublishAsync).RequireAuthorization(AuthorizationPolicies.IngestionReview)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>().AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateAsync([FromBody] CreateIngestionProposalRequest request, IWorkspaceRepository workspaces,
|
||||
CreateIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics, IOptions<IngestionProposalOptions> options,
|
||||
ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled();
|
||||
if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"].ToString()))
|
||||
return Results.Problem(statusCode: 400, detail: "Idempotency-Key-header is vereist.");
|
||||
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
|
||||
if (workspace is null) return NotFound();
|
||||
IngestionPublication? publication = null;
|
||||
if (request.Publication is not null)
|
||||
{
|
||||
if (!Enum.TryParse<IngestionMutationKind>(request.Publication.MutationKind, true, out var mutationKind))
|
||||
return Results.Problem(statusCode: 422, detail: "Ongeldige publicatiemutatie.");
|
||||
try
|
||||
{
|
||||
publication = IngestionPublication.Create(mutationKind, request.Publication.Title, request.Publication.Markdown,
|
||||
request.Publication.DocumentType, request.Publication.DocumentStatus, request.Publication.ParentPageId,
|
||||
request.Publication.TargetPageId, request.Publication.ExpectedTargetETag);
|
||||
}
|
||||
catch (DomainException ex)
|
||||
{
|
||||
return Results.Problem(statusCode: 422, detail: ex.Message);
|
||||
}
|
||||
}
|
||||
var result = await useCase.ExecuteAsync(new(workspace.Value, Actor(principal), request.Manifest,
|
||||
request.CorrelationId ?? Correlation(context), context.Request.Headers["Idempotency-Key"].ToString(), publication), ct);
|
||||
metrics.RecordProposal(result.Status == OperationStatus.Success ? "success" : "failure",
|
||||
result.Status == OperationStatus.Success ? "none" : "validation");
|
||||
if (result.Status != OperationStatus.Success) return Results.Problem(statusCode: 422, detail: string.Join(' ', result.Errors));
|
||||
var detail = await DetailAsync(proposals, workspace.Value, result.Value!, principal, ct);
|
||||
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
|
||||
return Results.Created($"/api/v1/ingestion-proposals/{detail.Id}", detail);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships,
|
||||
IIngestionProposalRepository proposals,
|
||||
IOptions<IngestionProposalOptions> options, HttpContext context, [FromQuery] string? status, [FromQuery] Guid? cursor,
|
||||
[FromQuery] int? limit, [FromQuery] int? page, [FromQuery] int? pageSize, [FromQuery] Guid? projectId,
|
||||
[FromQuery] Guid? submitterId, [FromQuery] int? minAgeDays, [FromQuery] string? risk, [FromQuery] string? documentType, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled();
|
||||
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
|
||||
if (workspace is null) return Results.Ok(new IngestionProposalListResponse([], null, 1, 50, 0, Correlation(context)));
|
||||
if (!TryStatus(status, out var parsed)) return Results.Problem(statusCode: 400, detail: "Ongeldige statusfilter.");
|
||||
if (!TryRisk(risk, out var parsedRisk)) return Results.Problem(statusCode: 400, detail: "Ongeldige risicofilter.");
|
||||
var actorId = Actor(context.User);
|
||||
Guid? privateStatusSubmitterId = await memberships.GetRoleAsync(workspace.Value, actorId, ct) == WorkspaceRole.Administrator
|
||||
? null
|
||||
: actorId;
|
||||
var queuePage = await proposals.ListAsync(new(
|
||||
workspace.Value,
|
||||
parsed,
|
||||
cursor,
|
||||
limit ?? 50,
|
||||
Math.Clamp(page ?? 1, 1, 10_000),
|
||||
pageSize is null ? null : Math.Clamp(pageSize.Value, 1, 100),
|
||||
projectId,
|
||||
submitterId,
|
||||
minAgeDays is null ? null : Math.Clamp(minAgeDays.Value, 0, 3650),
|
||||
parsedRisk,
|
||||
string.IsNullOrWhiteSpace(documentType) ? null : documentType.Trim(),
|
||||
privateStatusSubmitterId), ct);
|
||||
var items = queuePage.Items.Select(ToSummary).ToArray();
|
||||
return Results.Ok(new IngestionProposalListResponse(items, queuePage.NextCursor, queuePage.Page, queuePage.PageSize, queuePage.Total, Correlation(context)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(Guid proposalId, IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships,
|
||||
IIngestionProposalRepository proposals,
|
||||
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled();
|
||||
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
|
||||
if (workspace is null) return NotFound();
|
||||
var proposal = await proposals.FindReadOnlyAsync(workspace.Value, proposalId, ct);
|
||||
if (proposal is null || (IsServiceAccount(principal) && proposal.IntegrationActorId != Actor(principal))) return NotFound();
|
||||
if (!IsServiceAccount(principal) && IsSubmitterPrivateStatus(proposal.Status)
|
||||
&& !await IsSubmitterOrAdministratorAsync(proposal, workspace.Value, principal, memberships, ct)) return NotFound();
|
||||
var detail = ToDetail(proposal, await proposals.GetETagAsync(workspace.Value, proposalId, ct) ?? string.Empty, Permissions(principal));
|
||||
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
|
||||
return Results.Ok(detail);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PatchAsync(Guid proposalId, [FromBody] PatchIngestionProposalRequest request, IWorkspaceRepository workspaces,
|
||||
PatchIngestionProposalUseCase useCase, ApplyIngestionProposalAnalysisUseCase analysisUseCase,
|
||||
IWorkspaceMembershipRepository memberships, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options,
|
||||
ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled();
|
||||
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
|
||||
var proposal = await proposals.FindReadOnlyAsync(workspace.Value, proposalId, ct);
|
||||
if (proposal is null || (IsServiceAccount(principal) && proposal.IntegrationActorId != Actor(principal))) return NotFound();
|
||||
if (!IsServiceAccount(principal) && IsSubmitterPrivateStatus(proposal.Status)
|
||||
&& !await IsSubmitterOrAdministratorAsync(proposal, workspace.Value, principal, memberships, ct)) return NotFound();
|
||||
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
|
||||
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
|
||||
OperationResult result;
|
||||
if (IsServiceAccount(principal))
|
||||
{
|
||||
if (request.Manifest is not null || request.Rationale is not null)
|
||||
return Results.Problem(statusCode: 403, detail: "Serviceaccounts mogen alleen analyse vastleggen.");
|
||||
result = await analysisUseCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context), request.Analysis), ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (request.Analysis is not null)
|
||||
return Results.Problem(statusCode: 403, detail: "Analyse is voorbehouden aan het indienende serviceaccount.");
|
||||
result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context), request.Manifest, request.Rationale), ct);
|
||||
}
|
||||
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
|
||||
}
|
||||
|
||||
private static Task<IResult> RequestChangesAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
|
||||
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) =>
|
||||
DecideAsync(IngestionDecision.RequestChanges, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct);
|
||||
private static Task<IResult> ApproveAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
|
||||
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) =>
|
||||
DecideAsync(IngestionDecision.Approve, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct);
|
||||
private static Task<IResult> RejectAsync(Guid proposalId, [FromBody] IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
|
||||
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct) =>
|
||||
DecideAsync(IngestionDecision.Reject, proposalId, request, workspaces, useCase, proposals, options, principal, context, ct);
|
||||
|
||||
private static async Task<IResult> DecideAsync(IngestionDecision decision, Guid proposalId, IngestionProposalDecisionRequest request, IWorkspaceRepository workspaces,
|
||||
DecideIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
|
||||
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
|
||||
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context), decision, request.Comment), ct);
|
||||
context.RequestServices.GetRequiredService<IngestionMetrics>().RecordReviewDuration(result.Status == OperationStatus.Success ? "success" : "failure",
|
||||
System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds);
|
||||
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RetryAsync(Guid proposalId, IWorkspaceRepository workspaces, RetryIngestionProposalUseCase useCase,
|
||||
IIngestionProposalRepository proposals, IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
|
||||
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
|
||||
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
|
||||
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context)), ct);
|
||||
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PublishAsync(Guid proposalId, IWorkspaceRepository workspaces,
|
||||
PublishIngestionProposalUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics,
|
||||
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled();
|
||||
if (IsServiceAccount(principal)) return Results.Problem(statusCode: 403, detail: "Serviceaccounts mogen geen voorstellen publiceren.");
|
||||
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
|
||||
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
|
||||
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
|
||||
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
return Results.Problem(statusCode: 400, detail: "Idempotency-Key-header is vereist.");
|
||||
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, ETag(context), idempotencyKey,
|
||||
Actor(principal), Correlation(context)), ct);
|
||||
metrics.RecordStageDuration("publication", result.Status == IngestionProposalResultStatus.Success ? "success" : "failure",
|
||||
System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds);
|
||||
metrics.RecordPublication(result.Status == IngestionProposalResultStatus.Success ? "success" : "failure",
|
||||
result.Status == IngestionProposalResultStatus.Success ? "none" : "rejected");
|
||||
if (result.Status != IngestionProposalResultStatus.Success) return result.Status switch
|
||||
{
|
||||
IngestionProposalResultStatus.NotFound => NotFound(),
|
||||
IngestionProposalResultStatus.ConcurrencyConflict => Results.Problem(statusCode: 412, type: IngestionProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
|
||||
IngestionProposalResultStatus.InvalidTransition => Results.Problem(statusCode: 409, type: IngestionProblemTypes.InvalidTransition, detail: string.Join(' ', result.Errors ?? [])),
|
||||
IngestionProposalResultStatus.StaleTarget => Results.Problem(statusCode: 409, type: IngestionProblemTypes.StaleTarget,
|
||||
title: "Publicatiedoel is gewijzigd.", detail: "Het voorstel is teruggezet naar de reviewwachtrij."),
|
||||
_ => Results.Problem(statusCode: 422, detail: string.Join(' ', result.Errors ?? []))
|
||||
};
|
||||
|
||||
var detail = await DetailAsync(proposals, workspace.Value, proposalId, principal, ct);
|
||||
if (detail is null) return NotFound();
|
||||
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
|
||||
return Results.Ok(detail);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadSourceAsync(Guid proposalId, IFormFile file, IWorkspaceRepository workspaces,
|
||||
StageIngestionProposalSourceUseCase useCase, IIngestionProposalRepository proposals, IngestionMetrics metrics,
|
||||
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled(); var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct); if (workspace is null) return NotFound();
|
||||
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
|
||||
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
|
||||
if (file.Length > options.Value.MaximumSourceBytes) return Results.Problem(statusCode: 413, detail: "Bron is groter dan de toegestane limiet.");
|
||||
await using var source = file.OpenReadStream();
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), file.FileName, file.ContentType,
|
||||
source, Correlation(context), options.Value.MaximumSourceCount, ETag(context), ActorKind(principal), AuditSource(principal)), ct);
|
||||
metrics.RecordStageDuration("source_staging", result.Status == OperationStatus.Success ? "success" : "failure",
|
||||
System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds);
|
||||
if (result.Status != OperationStatus.Success) return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
|
||||
var detail = await DetailAsync(proposals, workspace.Value, proposalId, principal, ct);
|
||||
if (detail is null) return NotFound();
|
||||
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
|
||||
return Results.Ok(detail);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SubmitManualAsync(Guid proposalId, IWorkspaceRepository workspaces,
|
||||
SubmitManualIngestionProposalUseCase useCase, IIngestionProposalRepository proposals,
|
||||
IOptions<IngestionProposalOptions> options, ClaimsPrincipal principal, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!options.Value.Enabled) return Disabled();
|
||||
if (IsServiceAccount(principal)) return Results.Forbid();
|
||||
var workspace = await workspaces.GetDefaultWorkspaceIdAsync(ct);
|
||||
if (workspace is null) return NotFound();
|
||||
if (ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) is null)
|
||||
return Results.Problem(statusCode: 428, detail: "If-Match-header is vereist.");
|
||||
var result = await useCase.ExecuteAsync(new(workspace.Value, proposalId, Actor(principal), ETag(context)), ct);
|
||||
return await ToMutationResult(result, proposals, workspace.Value, proposalId, context, ct);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ToMutationResult(OperationResult result, IIngestionProposalRepository proposals, Guid workspaceId, Guid proposalId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (result.Status != OperationStatus.Success) return result.Status switch
|
||||
{
|
||||
OperationStatus.NotFound => NotFound(),
|
||||
OperationStatus.ConcurrencyConflict => Results.Problem(statusCode: 412, type: IngestionProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
|
||||
OperationStatus.InvalidTransition => Results.Problem(statusCode: 409, type: IngestionProblemTypes.InvalidTransition, detail: string.Join(' ', result.Errors)),
|
||||
_ => Validation(result)
|
||||
};
|
||||
var detail = await DetailAsync(proposals, workspaceId, proposalId, context.User, ct);
|
||||
if (detail is null) return NotFound();
|
||||
context.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail.ETag);
|
||||
return TypedResults.Ok(detail);
|
||||
}
|
||||
|
||||
private static async Task<IngestionProposalDetail?> DetailAsync(IIngestionProposalRepository proposals, Guid workspaceId, Guid id, ClaimsPrincipal principal, CancellationToken ct)
|
||||
{ var proposal = await proposals.FindAsync(workspaceId, id, ct); return proposal is null ? null : ToDetail(proposal, await proposals.GetETagAsync(workspaceId, id, ct) ?? string.Empty, Permissions(principal)); }
|
||||
private static IngestionProposalSummary ToSummary(IngestionProposal p) => new(p.Id, p.CorrelationId, p.Status.ToString(), p.RevisionNumber, p.Sources.Count,
|
||||
p.SubmittedByUserId, CreatedAt(p), Destination(p), Metadata(p), p.Publication?.MutationKind.ToString());
|
||||
private static IngestionProposalDetail ToDetail(IngestionProposal p, string etag, IngestionProposalPermissions permissions) => new(p.Id, p.CorrelationId, p.Status.ToString(), p.RevisionNumber,
|
||||
p.Manifest, p.Analysis, p.ReviewerComment, p.RetryCount, p.MaxPublishRetries, p.IsRetryEligible,
|
||||
p.FailedLifecycleStatus?.ToString(), p.FailedProcessingStage?.ToString(), p.FailureErrorType, p.FailureMessage,
|
||||
p.Sources.Select(s => new IngestionProposalSourceDetail(s.Id, s.FileName, s.MimeType, s.ByteSize, s.DigestSha256, s.ScanResult, s.AttachmentReferenceId)).ToArray(),
|
||||
p.Revisions.Select(r => new IngestionProposalRevisionDetail(r.RevisionNumber, r.Status.ToString(), r.Operation, r.ActorId, r.OccurredAt)).ToArray(), etag,
|
||||
p.PublishedPageId, p.PublishedPageRevisionNumber, p.SubmittedByUserId, CreatedAt(p), Destination(p), Metadata(p), p.Publication?.MutationKind.ToString(),
|
||||
p.Sources.Select(s => new IngestionProposalSourceDetail(s.Id, s.FileName, s.MimeType, s.ByteSize, s.DigestSha256, s.ScanResult, s.AttachmentReferenceId)).ToArray(),
|
||||
p.Publication?.ExpectedTargetETag, [], [], [], [],
|
||||
p.Publication is null ? null : new IngestionProposalDiff(null, p.Publication.Markdown, null), permissions);
|
||||
private static DateTimeOffset CreatedAt(IngestionProposal proposal) => proposal.Revisions.OrderBy(revision => revision.RevisionNumber).Select(revision => revision.OccurredAt).First();
|
||||
private static IngestionProposalDestination? Destination(IngestionProposal proposal) => proposal.Publication is null ? null : new(proposal.Publication.TargetPageId, proposal.Publication.ParentPageId, proposal.Publication.Title);
|
||||
private static IngestionProposalMetadata Metadata(IngestionProposal proposal) => new(proposal.Publication?.DocumentType, proposal.Publication?.DocumentStatus, null, null);
|
||||
private static IngestionProposalPermissions Permissions(ClaimsPrincipal principal) => IsServiceAccount(principal) ? new(false, false, false) : new(true, true, true);
|
||||
private static bool IsSubmitterPrivateStatus(IngestionProposalStatus status) =>
|
||||
status is IngestionProposalStatus.Received or IngestionProposalStatus.ChangesRequested;
|
||||
private static async Task<bool> IsSubmitterOrAdministratorAsync(IngestionProposal proposal, Guid workspaceId,
|
||||
ClaimsPrincipal principal, IWorkspaceMembershipRepository memberships, CancellationToken cancellationToken)
|
||||
{
|
||||
var actorId = Actor(principal);
|
||||
if (proposal.SubmittedByUserId == actorId) return true;
|
||||
return await memberships.GetRoleAsync(workspaceId, actorId, cancellationToken) == WorkspaceRole.Administrator;
|
||||
}
|
||||
private static IResult Disabled() => Results.Problem(statusCode: 404, type: IngestionProblemTypes.FeatureDisabled);
|
||||
private static IResult NotFound() => Results.Problem(statusCode: 404, type: IngestionProblemTypes.NotFound);
|
||||
private static IResult Validation(OperationResult result) => Results.Problem(statusCode: 422, detail: string.Join(' ', result.Errors));
|
||||
private static bool TryStatus(string? raw, out IngestionProposalStatus? status) { status = null; if (string.IsNullOrWhiteSpace(raw)) return true; if (!Enum.TryParse<IngestionProposalStatus>(raw, true, out var value)) return false; status = value; return true; }
|
||||
private static bool TryRisk(string? raw, out string? risk) { risk = null; if (string.IsNullOrWhiteSpace(raw)) return true; if (raw is "low" or "medium" or "high") { risk = raw; return true; } return false; }
|
||||
private static Guid Actor(ClaimsPrincipal principal) => Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
private static bool IsServiceAccount(ClaimsPrincipal principal) => principal.Identity?.AuthenticationType == ServiceTokenAuthenticationDefaults.Scheme;
|
||||
private static AuditActorKind ActorKind(ClaimsPrincipal principal) => IsServiceAccount(principal) ? AuditActorKind.ServiceAccount : AuditActorKind.User;
|
||||
private static string AuditSource(ClaimsPrincipal principal) => IsServiceAccount(principal) ? "api" : "web";
|
||||
private static string ETag(HttpContext context) => ETagHelper.FromHeaderValue(context.Request.Headers.IfMatch) ?? string.Empty;
|
||||
private static string Correlation(HttpContext context) => context.Items[CorrelationIdMiddleware.ItemKey] as string ?? "ingestion";
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class PagesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPagesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// No group-level blanket policy: GetPageAsync/CreatePageAsync/UpdatePageAsync
|
||||
// below accept either a cookie session or a scoped service-account bearer
|
||||
// token (docs/09-api-contract.md, M6), which requires each of those routes to
|
||||
// carry its own explicit authorization metadata rather than inheriting one
|
||||
// fixed, cookie-only group default — stacking a cookie-only group policy on
|
||||
// top of a dual-scheme route policy would silently block every bearer-only
|
||||
// caller (ADR-015). Every other route below keeps exactly its prior
|
||||
// cookie-only behavior, now stated explicitly per route instead of inherited.
|
||||
var group = app.MapGroup("/api/v1/pages").WithTags("Pages");
|
||||
|
||||
group.MapGet("/roots", GetRootsAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
group.MapGet("/{pageId:guid}", GetPageAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
group.MapGet("/{pageId:guid}/children", GetChildrenAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
group.MapGet("/{pageId:guid}/revisions", GetRevisionsAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
group.MapGet("/{pageId:guid}/revisions/{revisionNumber:long}", GetRevisionAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
group.MapGet("/{pageId:guid}/backlinks", GetBacklinksAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
group.MapPost("/preview", PreviewMarkdownAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
|
||||
group.MapPost("", CreatePageAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
// Staged creation (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md): the
|
||||
// atomic alternative to CreatePageAsync when attachments must be inserted
|
||||
// before the page is ever visible to a reader. Idempotency-Key is honored on
|
||||
// both begin and commit so a lost response never risks a duplicate session or
|
||||
// a duplicate page on client retry.
|
||||
group.MapPost("/staged", BeginPageCreationSessionAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/staged/{sessionId:guid}/commit", CommitPageCreationSessionAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/staged/{sessionId:guid}/discard", DiscardPageCreationSessionAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPut("/{pageId:guid}", UpdatePageAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/{pageId:guid}/move", MovePageAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/{pageId:guid}/trash", TrashPageAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.PagesWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{pageId:guid}/restore", RestorePageAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{pageId:guid}/revisions/{revisionNumber:long}/restore", RestoreRevisionAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<PreviewMarkdownResponse>> PreviewMarkdownAsync(
|
||||
[FromBody] PreviewMarkdownRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
LinkResolutionService links,
|
||||
IMarkdownRenderer renderer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Ok(new PreviewMarkdownResponse(renderer.RenderToSafeHtml(request.Markdown ?? string.Empty, new Dictionary<string, ResolvedLinkTarget>())));
|
||||
}
|
||||
|
||||
var resolution = await links.ResolveAsync(
|
||||
workspaceId.Value, Guid.NewGuid(), 0, request.Markdown ?? string.Empty, cancellationToken);
|
||||
return TypedResults.Ok(new PreviewMarkdownResponse(
|
||||
renderer.RenderToSafeHtml(request.Markdown ?? string.Empty, resolution.ResolvedTargets)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<IReadOnlyList<PageRevisionSummary>>, ProblemHttpResult>> GetRevisionsAsync(
|
||||
Guid pageId,
|
||||
GetPageRevisionsUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var revisions = await useCase.ExecuteAsync(pageId, cancellationToken);
|
||||
if (revisions is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound);
|
||||
}
|
||||
|
||||
IReadOnlyList<PageRevisionSummary> response = revisions
|
||||
.Select(revision => new PageRevisionSummary(
|
||||
revision.RevisionNumber,
|
||||
revision.Title,
|
||||
revision.DocumentType.ToString(),
|
||||
revision.DocumentStatus.ToString(),
|
||||
revision.AuthoredBy,
|
||||
revision.ChangeSummary,
|
||||
revision.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageRevisionDetailResponse>, ProblemHttpResult>> GetRevisionAsync(
|
||||
Guid pageId,
|
||||
long revisionNumber,
|
||||
GetPageRevisionUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await useCase.ExecuteAsync(pageId, revisionNumber, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new PageRevisionDetailResponse(
|
||||
result.Revision.RevisionNumber,
|
||||
result.Revision.Title,
|
||||
result.Revision.Markdown,
|
||||
result.RenderedHtml,
|
||||
result.Revision.DocumentType.ToString(),
|
||||
result.Revision.DocumentStatus.ToString(),
|
||||
result.Revision.AuthoredBy,
|
||||
result.Revision.ChangeSummary,
|
||||
result.Revision.CreatedAt));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> RestoreRevisionAsync(
|
||||
Guid pageId,
|
||||
long revisionNumber,
|
||||
RestorePageRevisionUseCase useCase,
|
||||
GetPageUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new RestorePageRevisionCommand(pageId, revisionNumber, ifMatch, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != PageOperationStatus.Success)
|
||||
{
|
||||
return ToProblem<PageDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(pageId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToPageDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<BacklinksResponse>, ProblemHttpResult>> GetBacklinksAsync(
|
||||
Guid pageId,
|
||||
GetPageBacklinksUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sources = await useCase.ExecuteAsync(pageId, cancellationToken);
|
||||
if (sources is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound);
|
||||
}
|
||||
|
||||
IReadOnlyList<BacklinkEntry> response = sources
|
||||
.Select(page => new BacklinkEntry(page.Id, page.TitleValue, page.SlugValue, page.DocumentType.ToString()))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(new BacklinksResponse(response));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageChildrenResponse>, ProblemHttpResult>> GetRootsAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
GetPageChildrenUseCase useCase,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(workspaceId.Value, null, cursor, limit ?? 50, cancellationToken);
|
||||
return TypedResults.Ok(ToChildrenResponse(result));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> GetPageAsync(
|
||||
Guid pageId,
|
||||
GetPageUseCase useCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await useCase.ExecuteAsync(pageId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound);
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
|
||||
return TypedResults.Ok(ToPageDetail(result));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageChildrenResponse>, ProblemHttpResult>> GetChildrenAsync(
|
||||
Guid pageId,
|
||||
IWorkspaceRepository workspaces,
|
||||
GetPageChildrenUseCase useCase,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(workspaceId.Value, pageId, cursor, limit ?? 50, cancellationToken);
|
||||
return TypedResults.Ok(ToChildrenResponse(result));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> CreatePageAsync(
|
||||
[FromBody] CreatePageRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
CreatePageUseCase createUseCase,
|
||||
GetPageUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await createUseCase.ExecuteAsync(
|
||||
new CreatePageCommand(
|
||||
workspaceId.Value,
|
||||
request.ParentPageId,
|
||||
request.Title,
|
||||
request.DocumentType,
|
||||
request.Markdown,
|
||||
request.TemplateKey,
|
||||
request.Tags,
|
||||
CurrentUserId(principal),
|
||||
correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != PageOperationStatus.Success)
|
||||
{
|
||||
return ToProblem<PageDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(result.PageId!.Value, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToPageDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageCreationSessionResponse>, ProblemHttpResult>> BeginPageCreationSessionAsync(
|
||||
[FromBody] BeginPageCreationSessionRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
BeginPageCreationSessionUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new BeginPageCreationSessionCommand(workspaceId.Value, request.ParentPageId, CurrentUserId(principal)),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != BeginPageCreationSessionStatus.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
detail: "Bovenliggende pagina bestaat niet.");
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new PageCreationSessionResponse(result.SessionId!.Value, result.ExpiresAt!.Value));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> CommitPageCreationSessionAsync(
|
||||
Guid sessionId,
|
||||
[FromBody] CommitPageCreationSessionRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
CommitPageCreationSessionUseCase commitUseCase,
|
||||
GetPageUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await commitUseCase.ExecuteAsync(
|
||||
new CommitPageCreationSessionCommand(
|
||||
workspaceId.Value, sessionId, request.Title, request.DocumentType, request.Markdown, request.Tags,
|
||||
CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != PageOperationStatus.Success)
|
||||
{
|
||||
if (result.Status == PageOperationStatus.NotFound)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.StagingSessionNotFound);
|
||||
}
|
||||
|
||||
if (result.Status == PageOperationStatus.ConcurrencyConflict)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.StagingSessionClosed,
|
||||
title: "Sessie niet meer open.",
|
||||
detail: string.Join(" ", result.Errors));
|
||||
}
|
||||
|
||||
return ToProblem<PageDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(result.PageId!.Value, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToPageDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<DiscardPageCreationSessionResponse>, ProblemHttpResult>> DiscardPageCreationSessionAsync(
|
||||
Guid sessionId,
|
||||
IWorkspaceRepository workspaces,
|
||||
DiscardPageCreationSessionUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(workspaceId.Value, sessionId, CurrentUserId(principal), correlationId, cancellationToken);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
DiscardPageCreationSessionStatus.Success => TypedResults.Ok(new DiscardPageCreationSessionResponse(result.AttachmentsRemoved)),
|
||||
DiscardPageCreationSessionStatus.NotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.StagingSessionNotFound),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.StagingSessionClosed,
|
||||
title: "Sessie is al gepubliceerd en kan niet meer worden verwijderd.")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> UpdatePageAsync(
|
||||
Guid pageId,
|
||||
[FromBody] UpdatePageRequest request,
|
||||
UpdatePageUseCase updateUseCase,
|
||||
GetPageUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status428PreconditionRequired,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "If-Match vereist.",
|
||||
detail: "Deze bewerking vereist een If-Match-header met de huidige ETag.");
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await updateUseCase.ExecuteAsync(
|
||||
new UpdatePageCommand(
|
||||
pageId,
|
||||
request.Title,
|
||||
request.Markdown,
|
||||
request.DocumentType,
|
||||
request.DocumentStatus,
|
||||
request.ChangeSummary,
|
||||
request.Tags,
|
||||
ifMatch,
|
||||
CurrentUserId(principal),
|
||||
correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != PageOperationStatus.Success)
|
||||
{
|
||||
return ToProblem<PageDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(pageId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToPageDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PageDetail>, ProblemHttpResult>> MovePageAsync(
|
||||
Guid pageId,
|
||||
[FromBody] MovePageRequest request,
|
||||
MovePageUseCase moveUseCase,
|
||||
GetPageUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status428PreconditionRequired,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "If-Match vereist.");
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await moveUseCase.ExecuteAsync(
|
||||
new MovePageCommand(pageId, request.NewParentPageId, request.AfterSiblingId, ifMatch, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != PageOperationStatus.Success)
|
||||
{
|
||||
return ToProblem<PageDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(pageId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToPageDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, ProblemHttpResult>> TrashPageAsync(
|
||||
Guid pageId,
|
||||
TrashPageUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new TrashPageCommand(pageId, ifMatch, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
return result.Status == PageOperationStatus.Success
|
||||
? TypedResults.Ok()
|
||||
: ToProblem<Ok>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, ProblemHttpResult>> RestorePageAsync(
|
||||
Guid pageId,
|
||||
RestorePageUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new RestorePageCommand(pageId, ifMatch, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
return result.Status == PageOperationStatus.Success
|
||||
? TypedResults.Ok()
|
||||
: ToProblem<Ok>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
private static ProblemHttpResult ToProblem<T>(PageOperationStatus status, IReadOnlyList<string> errors) =>
|
||||
status switch
|
||||
{
|
||||
PageOperationStatus.NotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.PageNotFound, title: "Pagina niet gevonden."),
|
||||
PageOperationStatus.Trashed => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.PageTrashed, title: "Pagina staat in de prullenbak."),
|
||||
PageOperationStatus.ConcurrencyConflict => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status412PreconditionFailed,
|
||||
type: ProblemTypes.ConcurrencyConflict,
|
||||
title: "Versieconflict.",
|
||||
detail: "Een andere gebruiker heeft deze pagina intussen gewijzigd."),
|
||||
PageOperationStatus.Cycle => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.PageCycle, title: "Ongeldige verplaatsing.", detail: string.Join(" ", errors)),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Ongeldige aanvraag.",
|
||||
detail: string.Join(" ", errors))
|
||||
};
|
||||
|
||||
private static PageChildrenResponse ToChildrenResponse(PageChildrenResult result) =>
|
||||
new(
|
||||
result.Items.Select(item => ToPageSummary(item.Page, item.HasChildren)).ToList(),
|
||||
result.NextCursor);
|
||||
|
||||
private static PageSummary ToPageSummary(Page page, bool hasChildren) =>
|
||||
new(
|
||||
page.Id,
|
||||
page.ParentPageId,
|
||||
page.TitleValue,
|
||||
page.SlugValue,
|
||||
page.DocumentType.ToString(),
|
||||
page.DocumentStatus.ToString(),
|
||||
hasChildren,
|
||||
page.UpdatedAt);
|
||||
|
||||
private static PageDetail ToPageDetail(PageDetailResult result) =>
|
||||
new(
|
||||
result.Page.Id,
|
||||
result.Page.WorkspaceId,
|
||||
result.Page.ParentPageId,
|
||||
result.Page.TitleValue,
|
||||
result.Page.SlugValue,
|
||||
result.Page.Markdown,
|
||||
result.RenderedHtml,
|
||||
result.Page.DocumentType.ToString(),
|
||||
result.Page.DocumentStatus.ToString(),
|
||||
result.Page.CurrentRevisionNumber,
|
||||
result.Page.Tags,
|
||||
result.Breadcrumb.Select(page => new BreadcrumbEntry(page.Id, page.TitleValue)).ToList(),
|
||||
result.Page.CreatedBy,
|
||||
result.Page.UpdatedBy,
|
||||
result.Page.CreatedAt,
|
||||
result.Page.UpdatedAt,
|
||||
result.Page.PublishedAt,
|
||||
result.Page.TrashedAt,
|
||||
result.ETag);
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Projects;
|
||||
using ItWorx.KnowledgeHub.Application.Projects.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Projects;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Projects;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class ProjectsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProjectsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// See PagesEndpoints.cs's identical comment: no group-level blanket policy
|
||||
// because some routes below accept either a cookie session or a scoped
|
||||
// service-account bearer token (ADR-015).
|
||||
var group = app.MapGroup("/api/v1/projects").WithTags("Projects");
|
||||
|
||||
group.MapGet("", ListAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
|
||||
group.MapGet("/totals", GetTotalsAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
|
||||
group.MapGet("/{projectId:guid}", GetAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
|
||||
group.MapGet("/{projectId:guid}/context", GetContextAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.ProjectsRead);
|
||||
group.MapGet("/{projectId:guid}/updates", GetUpdatesAsync).RequireAuthorization(AuthorizationPolicies.RequireReader);
|
||||
group.MapGet("/{projectId:guid}/milestones", GetMilestonesAsync).RequireAuthorization(AuthorizationPolicies.RequireReader);
|
||||
|
||||
// Unlike every other content-creation/update endpoint in this API (Pages,
|
||||
// Decisions, Runbooks all use RequireContentAccess + IdempotencyEndpointFilter
|
||||
// for create, and Pages' own UpdatePageAsync uses the same for update), Create
|
||||
// and Update here were both still cookie-only — meaning the documented,
|
||||
// selectable `projects:write` scope (docs/09-api-contract.md, docs/guides/
|
||||
// service-accounts-en-automatisering.md) was entirely unusable by any service
|
||||
// account, for either action. Found while trying to publish real project
|
||||
// dossiers via a scoped automation token (docs/v2/server-enrichment/): Create
|
||||
// alone was not enough, since CreateProjectRequest only carries the four core
|
||||
// fields (name/summary/priority/currentStateMarkdown) — repository/live URLs,
|
||||
// dates and technologies only exist on UpdateProjectRequest. Fixed both to
|
||||
// match the established pattern exactly. Secondary lifecycle endpoints below
|
||||
// (status/documents/milestones) deliberately keep the narrower cookie-only
|
||||
// policy for now, matching DecisionsEndpoints' own accept/reject/withdraw —
|
||||
// not touched by this fix, no proven need yet.
|
||||
group.MapPost("", CreateAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPut("/{projectId:guid}", UpdateAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/{projectId:guid}/icon", SetIconAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectsWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.DisableAntiforgery()
|
||||
.WithMetadata(new RequestSizeLimitAttribute(SetProjectIconUseCase.MaximumIconBytes + 64 * 1024));
|
||||
|
||||
group.MapPost("/{projectId:guid}/status", ChangeStatusAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{projectId:guid}/documents", LinkDocumentAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapDelete("/documents/{documentId:guid}", UnlinkDocumentAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{projectId:guid}/updates", CreateUpdateAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.ProjectUpdatesAppend)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/{projectId:guid}/milestones", CreateMilestoneAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPut("/milestones/{milestoneId:guid}", UpdateMilestoneAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<ProjectListResponse>> ListAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
ListProjectsUseCase useCase,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? priority,
|
||||
[FromQuery] Guid? ownerId,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] DateTimeOffset? updatedAfter,
|
||||
[FromQuery] string? sort,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Ok(new ProjectListResponse([], null, 0));
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
workspaceId.Value, status, priority, ownerId, search, updatedAfter, sort, cursor, limit ?? 50, cancellationToken);
|
||||
return TypedResults.Ok(new ProjectListResponse(
|
||||
result.Items.Select(ToProjectSummary).ToList(), result.NextCursor, result.TotalCount));
|
||||
}
|
||||
|
||||
private static async Task<Ok<ProjectCatalogTotalsResponse>> GetTotalsAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
GetProjectCatalogTotalsUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Ok(new ProjectCatalogTotalsResponse(0, 0));
|
||||
}
|
||||
|
||||
var totals = await useCase.ExecuteAsync(workspaceId.Value, cancellationToken);
|
||||
return TypedResults.Ok(new ProjectCatalogTotalsResponse(totals.TotalCount, totals.ActiveCount));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> GetAsync(
|
||||
Guid projectId, GetProjectOverviewUseCase useCase, HttpContext httpContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await useCase.ExecuteAsync(projectId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound);
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
|
||||
return TypedResults.Ok(ToProjectDetail(result));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ProjectContextResponse>, ProblemHttpResult>> GetContextAsync(
|
||||
Guid projectId,
|
||||
GetProjectContextUseCase useCase,
|
||||
[FromQuery] int? updates,
|
||||
[FromQuery] int? decisions,
|
||||
[FromQuery] int? documents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await useCase.ExecuteAsync(projectId, updates ?? 10, decisions ?? 10, documents ?? 10, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new ProjectContextResponse(
|
||||
new ProjectContextProjectSummary(
|
||||
result.Project.Id, result.Project.Name, result.Project.Status.ToString(), result.Project.Summary,
|
||||
result.Project.CurrentStateMarkdown, result.Project.UpdatedAt),
|
||||
result.RecentUpdates.Select(update => new ProjectUpdateSummary(
|
||||
update.Id, update.Category.ToString(), update.Title, update.SummaryMarkdown, update.VerificationStatus.ToString(),
|
||||
update.ActorId, update.OccurredAt, update.CommitSha, update.ReleaseTag, update.IssueIdentifier, update.Url,
|
||||
update.PageId, update.DecisionRecordId, update.MilestoneId, update.AttachmentReferenceId, update.SupersedesUpdateId))
|
||||
.ToList(),
|
||||
result.ActiveMilestones.Select(milestone => new ProjectMilestoneSummary(
|
||||
milestone.Id, milestone.Name, milestone.State.ToString(),
|
||||
milestone.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
milestone.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
milestone.Summary, milestone.SuccessCriteria, string.Empty))
|
||||
.ToList(),
|
||||
result.Decisions.Select(decision => new ProjectDecisionSummary(
|
||||
decision.Id, decision.DecisionNumber, decision.Status.ToString(), decision.Summary))
|
||||
.ToList(),
|
||||
result.Runbooks.Select(runbook => new ProjectDocumentSummary(
|
||||
runbook.Document.Id, runbook.Document.PageId, runbook.PageTitle, runbook.PageSlug, runbook.PageDocumentType,
|
||||
runbook.Document.Role.ToString()))
|
||||
.ToList(),
|
||||
result.Documents.Select(document => new ProjectDocumentSummary(
|
||||
document.Document.Id, document.Document.PageId, document.PageTitle, document.PageSlug, document.PageDocumentType,
|
||||
document.Document.Role.ToString()))
|
||||
.ToList(),
|
||||
result.Citations.Select(citation => new CitationResponse(
|
||||
citation.Kind, citation.Id, citation.Revision, citation.Title, citation.CanonicalPath, citation.UpdatedAt))
|
||||
.ToList(),
|
||||
new ProjectContextTruncation(result.Truncated, result.OmittedCounts)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> CreateAsync(
|
||||
[FromBody] CreateProjectRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
CreateProjectUseCase createUseCase,
|
||||
GetProjectOverviewUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await createUseCase.ExecuteAsync(
|
||||
new CreateProjectCommand(
|
||||
workspaceId.Value, request.Name, request.Summary, request.Priority, request.CurrentStateMarkdown,
|
||||
request.OwnerId, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem<ProjectDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(result.Value, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToProjectDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ProjectIconResponse>, ProblemHttpResult>> SetIconAsync(
|
||||
Guid projectId,
|
||||
IFormFile file,
|
||||
IWorkspaceRepository workspaces,
|
||||
SetProjectIconUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
if (file.Length > SetProjectIconUseCase.MaximumIconBytes)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status413PayloadTooLarge,
|
||||
type: ProblemTypes.AttachmentTooLarge,
|
||||
detail: "Een projecticoon mag maximaal 2 MiB groot zijn.");
|
||||
}
|
||||
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new SetProjectIconCommand(
|
||||
workspaceId.Value,
|
||||
projectId,
|
||||
CurrentUserId(principal),
|
||||
file.FileName,
|
||||
file.ContentType,
|
||||
stream,
|
||||
ifMatch,
|
||||
httpContext.Items[CorrelationIdMiddleware.ItemKey] as string),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != SetProjectIconStatus.Success)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
SetProjectIconStatus.NotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound, title: "Project niet gevonden."),
|
||||
SetProjectIconStatus.ConcurrencyConflict => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict,
|
||||
title: "Versieconflict.", detail: "Het project is intussen gewijzigd. Vernieuw de pagina en probeer opnieuw."),
|
||||
SetProjectIconStatus.TooLarge => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status413PayloadTooLarge, type: ProblemTypes.AttachmentTooLarge,
|
||||
detail: "Een projecticoon mag maximaal 2 MiB groot zijn."),
|
||||
SetProjectIconStatus.EmptyFile => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: "Kies een afbeelding."),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.AttachmentRejected,
|
||||
detail: result.Detail ?? "Kies een geldige PNG-, JPEG-, GIF- of WebP-afbeelding.")
|
||||
};
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(new ProjectIconResponse(result.IconAttachmentId!.Value, result.ETag!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> UpdateAsync(
|
||||
Guid projectId,
|
||||
[FromBody] UpdateProjectRequest request,
|
||||
UpdateProjectUseCase updateUseCase,
|
||||
GetProjectOverviewUseCase getUseCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await updateUseCase.ExecuteAsync(
|
||||
new UpdateProjectCommand(
|
||||
projectId, request.Name, request.Summary, request.Priority, request.CurrentStateMarkdown, request.OwnerId,
|
||||
request.StartDate, request.TargetDate, request.CompletionDate, request.RepositoryUrl, request.LiveUrl,
|
||||
request.DocumentationUrl, request.IssueTrackerUrl, request.Technologies, ifMatch,
|
||||
CurrentUserId(httpContext.User), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem<ProjectDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(projectId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToProjectDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ProjectDetail>, ProblemHttpResult>> ChangeStatusAsync(
|
||||
Guid projectId,
|
||||
[FromBody] ChangeProjectStatusRequest request,
|
||||
ChangeProjectStatusUseCase useCase,
|
||||
GetProjectOverviewUseCase getUseCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new ChangeProjectStatusCommand(projectId, request.Status, ifMatch, CurrentUserId(httpContext.User), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem<ProjectDetail>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(projectId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(ToProjectDetail(detail!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ProjectDocumentSummary>, ProblemHttpResult>> LinkDocumentAsync(
|
||||
Guid projectId,
|
||||
[FromBody] LinkProjectDocumentRequest request,
|
||||
LinkProjectDocumentUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new LinkProjectDocumentCommand(projectId, request.PageId, request.Role, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem<ProjectDocumentSummary>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new ProjectDocumentSummary(result.Value, request.PageId, string.Empty, string.Empty, string.Empty, request.Role));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, ProblemHttpResult>> UnlinkDocumentAsync(
|
||||
Guid documentId, UnlinkProjectDocumentUseCase useCase, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new UnlinkProjectDocumentCommand(documentId, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
return result.Status == OperationStatus.Success ? TypedResults.Ok() : ToProblem<Ok>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
private static async Task<Ok<ProjectUpdateSummary>> CreateUpdateAsync(
|
||||
Guid projectId,
|
||||
[FromBody] CreateProjectUpdateRequest request,
|
||||
CreateProjectUpdateUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new CreateProjectUpdateCommand(
|
||||
projectId, request.Category, request.Title, request.SummaryMarkdown, request.VerificationStatus,
|
||||
request.OccurredAt, request.CommitSha, request.ReleaseTag, request.IssueIdentifier, request.Url,
|
||||
request.PageId, request.DecisionRecordId, request.MilestoneId, request.AttachmentReferenceId,
|
||||
request.SupersedesUpdateId, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new ProjectUpdateSummary(
|
||||
result.Value, request.Category, request.Title, request.SummaryMarkdown, request.VerificationStatus,
|
||||
CurrentUserId(principal), DateTimeOffset.UtcNow, request.CommitSha, request.ReleaseTag, request.IssueIdentifier,
|
||||
request.Url, request.PageId, request.DecisionRecordId, request.MilestoneId, request.AttachmentReferenceId,
|
||||
request.SupersedesUpdateId));
|
||||
}
|
||||
|
||||
private static async Task<Ok<ProjectUpdatesResponse>> GetUpdatesAsync(
|
||||
Guid projectId, GetProjectUpdatesUseCase useCase, [FromQuery] string? cursor, [FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await useCase.ExecuteAsync(projectId, cursor, limit ?? 25, cancellationToken);
|
||||
return TypedResults.Ok(new ProjectUpdatesResponse(
|
||||
result.Items.Select(update => new ProjectUpdateSummary(
|
||||
update.Id, update.Category.ToString(), update.Title, update.SummaryMarkdown, update.VerificationStatus.ToString(),
|
||||
update.ActorId, update.OccurredAt, update.CommitSha, update.ReleaseTag, update.IssueIdentifier, update.Url,
|
||||
update.PageId, update.DecisionRecordId, update.MilestoneId, update.AttachmentReferenceId, update.SupersedesUpdateId))
|
||||
.ToList(),
|
||||
result.NextCursor));
|
||||
}
|
||||
|
||||
private static async Task<Ok<ProjectMilestoneSummary>> CreateMilestoneAsync(
|
||||
Guid projectId,
|
||||
[FromBody] CreateProjectMilestoneRequest request,
|
||||
CreateProjectMilestoneUseCase createUseCase,
|
||||
IProjectRepository projects,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await createUseCase.ExecuteAsync(
|
||||
new CreateProjectMilestoneCommand(
|
||||
projectId, request.Name, request.TargetDate, request.Summary, request.SuccessCriteria,
|
||||
CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
var created = await projects.FindMilestoneAsync(result.Value, cancellationToken);
|
||||
var etag = await projects.GetMilestoneETagAsync(result.Value, cancellationToken) ?? string.Empty;
|
||||
return TypedResults.Ok(ToMilestoneSummary(created!, etag));
|
||||
}
|
||||
|
||||
private static async Task<Ok<IReadOnlyList<ProjectMilestoneSummary>>> GetMilestonesAsync(
|
||||
Guid projectId, GetProjectMilestonesUseCase useCase, IProjectRepository projects, CancellationToken cancellationToken)
|
||||
{
|
||||
var milestones = await useCase.ExecuteAsync(projectId, cancellationToken);
|
||||
var summaries = new List<ProjectMilestoneSummary>(milestones.Count);
|
||||
foreach (var milestone in milestones)
|
||||
{
|
||||
var etag = await projects.GetMilestoneETagAsync(milestone.Id, cancellationToken) ?? string.Empty;
|
||||
summaries.Add(ToMilestoneSummary(milestone, etag));
|
||||
}
|
||||
|
||||
return TypedResults.Ok<IReadOnlyList<ProjectMilestoneSummary>>(summaries);
|
||||
}
|
||||
|
||||
private static ProjectMilestoneSummary ToMilestoneSummary(ProjectMilestone milestone, string etag) =>
|
||||
new(
|
||||
milestone.Id, milestone.Name, milestone.State.ToString(),
|
||||
milestone.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
milestone.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
milestone.Summary, milestone.SuccessCriteria, etag);
|
||||
|
||||
private static async Task<Results<Ok<ProjectMilestoneSummary>, ProblemHttpResult>> UpdateMilestoneAsync(
|
||||
Guid milestoneId,
|
||||
[FromBody] UpdateProjectMilestoneRequest request,
|
||||
UpdateProjectMilestoneUseCase useCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new UpdateProjectMilestoneCommand(
|
||||
milestoneId, request.Name, request.TargetDate, request.Summary, request.SuccessCriteria, request.State,
|
||||
ifMatch, CurrentUserId(httpContext.User), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem<ProjectMilestoneSummary>(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag!);
|
||||
return TypedResults.Ok(new ProjectMilestoneSummary(
|
||||
milestoneId, request.Name, request.State, request.TargetDate, null, request.Summary, request.SuccessCriteria, result.ETag!));
|
||||
}
|
||||
|
||||
private static ProblemHttpResult ToProblem<T>(OperationStatus status, IReadOnlyList<string> errors) =>
|
||||
status switch
|
||||
{
|
||||
OperationStatus.NotFound => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ProjectNotFound, title: "Project niet gevonden."),
|
||||
OperationStatus.ConcurrencyConflict => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict,
|
||||
title: "Versieconflict.", detail: "Een andere gebruiker heeft dit intussen gewijzigd."),
|
||||
OperationStatus.InvalidTransition => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict, type: ProblemTypes.ProjectInvalidTransition,
|
||||
title: "Ongeldige statusovergang.", detail: string.Join(" ", errors)),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
|
||||
title: "Ongeldige aanvraag.", detail: string.Join(" ", errors))
|
||||
};
|
||||
|
||||
private static ProjectSummary ToProjectSummary(Domain.Projects.Project project) =>
|
||||
new(project.Id, project.Name, project.Slug, project.Summary, project.Status.ToString(), project.Priority.ToString(),
|
||||
project.OwnerId, project.RepositoryUrl, project.LiveUrl, project.UpdatedAt, project.IconAttachmentReferenceId);
|
||||
|
||||
private static ProjectDetail ToProjectDetail(ProjectOverviewResult result) =>
|
||||
new(
|
||||
result.Project.Id, result.Project.Name, result.Project.Slug, result.Project.Summary,
|
||||
result.Project.Status.ToString(), result.Project.Priority.ToString(), result.Project.CurrentStateMarkdown,
|
||||
result.Project.OwnerId,
|
||||
result.Project.StartDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
result.Project.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
result.Project.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
result.Project.RepositoryUrl, result.Project.LiveUrl, result.Project.DocumentationUrl, result.Project.IssueTrackerUrl,
|
||||
result.Project.Technologies,
|
||||
result.Documents.Select(document => new ProjectDocumentSummary(
|
||||
document.Document.Id, document.Document.PageId, document.PageTitle, document.PageSlug, document.PageDocumentType,
|
||||
document.Document.Role.ToString())).ToList(),
|
||||
result.RecentUpdates.Select(update => new ProjectUpdateSummary(
|
||||
update.Id, update.Category.ToString(), update.Title, update.SummaryMarkdown, update.VerificationStatus.ToString(),
|
||||
update.ActorId, update.OccurredAt, update.CommitSha, update.ReleaseTag, update.IssueIdentifier, update.Url,
|
||||
update.PageId, update.DecisionRecordId, update.MilestoneId, update.AttachmentReferenceId, update.SupersedesUpdateId))
|
||||
.ToList(),
|
||||
result.Milestones.Select(milestone => new ProjectMilestoneSummary(
|
||||
milestone.Id, milestone.Name, milestone.State.ToString(),
|
||||
milestone.TargetDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
milestone.CompletionDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
|
||||
milestone.Summary, milestone.SuccessCriteria, string.Empty))
|
||||
.ToList(),
|
||||
result.Decisions.Select(decision => new ProjectDecisionSummary(
|
||||
decision.Id, decision.DecisionNumber, decision.Status.ToString(), decision.Summary))
|
||||
.ToList(),
|
||||
result.Project.CreatedAt, result.Project.UpdatedAt, result.ETag, result.Project.IconAttachmentReferenceId);
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
|
||||
public sealed record ProjectUpdatesResponse(IReadOnlyList<ItWorx.KnowledgeHub.Contracts.Projects.ProjectUpdateSummary> Items, string? NextCursor);
|
||||
@@ -0,0 +1,81 @@
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// A single, deliberately narrow read-only endpoint for RAGcore's n8n sync
|
||||
/// (docs/32-rag-publication-convention.md) — reuses the existing Reader/
|
||||
/// <c>pages:read</c> content-access policy, no new scope. Not a general
|
||||
/// <c>GET /pages</c> enumeration: only pages explicitly opted in via the
|
||||
/// <c>rag:publish</c> tag are ever returned.
|
||||
/// </summary>
|
||||
public static class RagPublicationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRagPublicationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/rag/publication-manifest", GetPublicationManifestAsync)
|
||||
.WithTags("Rag")
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.PagesRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RagPublicationManifestResponse>, ProblemHttpResult>> GetPublicationManifestAsync(
|
||||
[FromQuery] string? space,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
IWorkspaceRepository workspaces,
|
||||
GetRagPublicationManifestUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new RagPublicationManifestQuery(workspaceId.Value, space, cursor, limit ?? 100), cancellationToken);
|
||||
|
||||
if (result.Status == RagPublicationManifestStatus.InvalidSpace)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Ongeldige space-parameter.",
|
||||
detail: "space moet een korte, kleine-letters slug zijn (a-z, 0-9, '-', '_', '.').");
|
||||
}
|
||||
|
||||
IReadOnlyList<RagPublicationManifestItem> items = result.Items
|
||||
.Select(item => new RagPublicationManifestItem(
|
||||
// Application's RagPublicationManifestEntry -> Contracts'
|
||||
// RagPublicationManifestItem (never returning the domain/application
|
||||
// record directly from an endpoint).
|
||||
item.ContentId,
|
||||
item.DocumentType,
|
||||
item.DocumentStatus,
|
||||
item.Title,
|
||||
item.Slug,
|
||||
item.CanonicalUrl,
|
||||
item.Markdown,
|
||||
item.Revision,
|
||||
item.Checksum,
|
||||
item.Locale,
|
||||
item.Space,
|
||||
item.UpdatedAt,
|
||||
item.PublishedAt,
|
||||
item.Retired,
|
||||
item.RetiredReason))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(new RagPublicationManifestResponse(items, result.NextCursor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Runbooks.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Runbooks;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class RunbooksEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRunbooksEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// See PagesEndpoints.cs's identical comment (ADR-015).
|
||||
var group = app.MapGroup("/api/v1/runbooks").WithTags("Runbooks");
|
||||
|
||||
group.MapGet("", ListAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.RunbooksRead);
|
||||
group.MapGet("/{runbookId:guid}", GetAsync).RequireContentAccess(WorkspaceRole.Reader, Scopes.RunbooksRead);
|
||||
|
||||
group.MapPost("", CreateAsync)
|
||||
.RequireContentAccess(WorkspaceRole.Editor, Scopes.RunbooksWrite)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.AddEndpointFilter<IdempotencyEndpointFilter>();
|
||||
|
||||
group.MapPost("/{runbookId:guid}/validate", ValidateAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPut("/{runbookId:guid}/profile", UpdateProfileAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<RunbookListResponse>> ListAsync(
|
||||
IWorkspaceRepository workspaces,
|
||||
ListRunbooksUseCase useCase,
|
||||
GetRunbookUseCase getUseCase,
|
||||
[FromQuery] string? riskLevel,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Ok(new RunbookListResponse([], null));
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(workspaceId.Value, riskLevel, status, cursor, limit ?? 50, cancellationToken);
|
||||
|
||||
var items = new List<RunbookSummary>(result.Items.Count);
|
||||
foreach (var runbook in result.Items)
|
||||
{
|
||||
var detail = await getUseCase.ExecuteAsync(runbook.Id, cancellationToken);
|
||||
if (detail is not null)
|
||||
{
|
||||
items.Add(new RunbookSummary(
|
||||
runbook.Id, runbook.PageId, detail.Page.Page.TitleValue, runbook.RiskLevel.ToString(),
|
||||
runbook.Status.ToString(), detail.IsStale, runbook.LastValidatedAt, runbook.UpdatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new RunbookListResponse(items, result.NextCursor));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> GetAsync(
|
||||
Guid runbookId, GetRunbookUseCase useCase, HttpContext httpContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await useCase.ExecuteAsync(runbookId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.RunbookNotFound);
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(result.ETag);
|
||||
return TypedResults.Ok(ToRunbookDetail(result));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> CreateAsync(
|
||||
[FromBody] CreateRunbookRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
CreateRunbookUseCase createUseCase,
|
||||
GetRunbookUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await createUseCase.ExecuteAsync(
|
||||
new CreateRunbookCommand(
|
||||
workspaceId.Value, request.ParentPageId, request.Title, request.Markdown, request.TemplateKey,
|
||||
request.RiskLevel, request.RequiresMaintenanceWindow, request.EstimatedDurationMinutes,
|
||||
CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", result.Errors));
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(result.Value, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
|
||||
return TypedResults.Ok(ToRunbookDetail(detail));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> ValidateAsync(
|
||||
Guid runbookId,
|
||||
ValidateRunbookUseCase useCase,
|
||||
GetRunbookUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new ValidateRunbookCommand(runbookId, ifMatch, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(runbookId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
|
||||
return TypedResults.Ok(ToRunbookDetail(detail));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RunbookDetail>, ProblemHttpResult>> UpdateProfileAsync(
|
||||
Guid runbookId,
|
||||
[FromBody] UpdateRunbookProfileRequest request,
|
||||
UpdateRunbookProfileUseCase useCase,
|
||||
GetRunbookUseCase getUseCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ifMatch = ETagHelper.FromHeaderValue(httpContext.Request.Headers.IfMatch);
|
||||
if (ifMatch is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status428PreconditionRequired, type: ProblemTypes.Validation);
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new UpdateRunbookProfileCommand(
|
||||
runbookId, request.Owner, request.RiskLevel, request.Status, request.RequiresMaintenanceWindow,
|
||||
request.EstimatedDurationMinutes, request.ReviewDueAt, ifMatch, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status != OperationStatus.Success)
|
||||
{
|
||||
return ToProblem(result.Status, result.Errors);
|
||||
}
|
||||
|
||||
var detail = await getUseCase.ExecuteAsync(runbookId, cancellationToken);
|
||||
httpContext.Response.Headers.ETag = ETagHelper.ToHeaderValue(detail!.ETag);
|
||||
return TypedResults.Ok(ToRunbookDetail(detail));
|
||||
}
|
||||
|
||||
private static ProblemHttpResult ToProblem(OperationStatus status, IReadOnlyList<string> errors) =>
|
||||
status switch
|
||||
{
|
||||
OperationStatus.NotFound => TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.RunbookNotFound),
|
||||
OperationStatus.ConcurrencyConflict => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status412PreconditionFailed, type: ProblemTypes.ConcurrencyConflict, title: "Versieconflict."),
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation, detail: string.Join(" ", errors))
|
||||
};
|
||||
|
||||
private static RunbookDetail ToRunbookDetail(RunbookDetailResult result) =>
|
||||
new(
|
||||
result.Runbook.Id, result.Runbook.PageId, result.Runbook.Owner, result.Runbook.RiskLevel.ToString(),
|
||||
result.Runbook.Status.ToString(), result.Runbook.RequiresMaintenanceWindow, result.Runbook.EstimatedDurationMinutes,
|
||||
result.Runbook.ReviewDueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
result.Runbook.LastValidatedAt, result.Runbook.ValidatedBy, result.IsStale,
|
||||
result.Page.Page.TitleValue, result.Page.Page.Markdown, result.Page.RenderedHtml, result.Page.Page.CurrentRevisionNumber,
|
||||
result.Runbook.CreatedAt, result.Runbook.UpdatedAt, result.ETag);
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class SearchEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSearchEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/search", SearchAsync)
|
||||
.WithTags("Search")
|
||||
.RequireContentAccess(WorkspaceRole.Reader, Scopes.KnowledgeSearch);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<SearchResponse>, ProblemHttpResult>> SearchAsync(
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? type,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
IWorkspaceRepository workspaces,
|
||||
SearchPagesUseCase useCase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new SearchPagesQuery(workspaceId.Value, q ?? string.Empty, tag, type, cursor, limit ?? 25),
|
||||
cancellationToken);
|
||||
|
||||
IReadOnlyList<SearchResultItem> items = result.Items
|
||||
.Select(hit => new SearchResultItem(
|
||||
hit.Id, hit.ParentPageId, hit.Title, hit.Slug, hit.DocumentType, hit.DocumentStatus, hit.Tags, hit.UpdatedAt, hit.Snippet))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(new SearchResponse(items, result.NextCursor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Contracts.Automation;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Administrative, cookie-authenticated-only endpoints for creating, listing,
|
||||
/// rotating and revoking service accounts/tokens (docs/09-api-contract.md "Service
|
||||
/// accounts": "Administrative browser/API endpoints create, list, rotate and revoke
|
||||
/// accounts/tokens."). Deliberately not reachable by a service token itself — a
|
||||
/// service account can never mint or escalate its own credentials.
|
||||
/// </summary>
|
||||
public static class ServiceAccountsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapServiceAccountsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/service-accounts")
|
||||
.WithTags("Service accounts")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
|
||||
group.MapPost("", CreateAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/{serviceAccountId:guid}/tokens", IssueTokenAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
group.MapPost("/tokens/{tokenId:guid}/revoke", RevokeTokenAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<ServiceAccountListResponse>> ListAsync(
|
||||
IWorkspaceRepository workspaces, ListServiceAccountsUseCase useCase, CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Ok(new ServiceAccountListResponse([]));
|
||||
}
|
||||
|
||||
var accounts = await useCase.ExecuteAsync(workspaceId.Value, cancellationToken);
|
||||
return TypedResults.Ok(new ServiceAccountListResponse(accounts.Select(ToSummary).ToList()));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<CreatedServiceAccountToken>, ProblemHttpResult>> CreateAsync(
|
||||
[FromBody] CreateServiceAccountRequest request,
|
||||
IWorkspaceRepository workspaces,
|
||||
CreateServiceAccountUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.NotFound);
|
||||
}
|
||||
|
||||
var invalidScopes = request.Scopes.Where(scope => !Scopes.IsValid(scope)).ToList();
|
||||
if (invalidScopes.Count > 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
|
||||
detail: $"Onbekende scope(s): {string.Join(", ", invalidScopes)}.");
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new CreateServiceAccountCommand(workspaceId.Value, request.Name, request.Description, request.Scopes, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new CreatedServiceAccountToken(result.ServiceAccountId, result.TokenId, result.RawToken));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<CreatedServiceAccountToken>, ProblemHttpResult>> IssueTokenAsync(
|
||||
Guid serviceAccountId,
|
||||
[FromBody] IssueServiceAccountTokenRequest request,
|
||||
IssueServiceAccountTokenUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invalidScopes = request.Scopes.Where(scope => !Scopes.IsValid(scope)).ToList();
|
||||
if (invalidScopes.Count > 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity, type: ProblemTypes.Validation,
|
||||
detail: $"Onbekende scope(s): {string.Join(", ", invalidScopes)}.");
|
||||
}
|
||||
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new IssueServiceAccountTokenCommand(serviceAccountId, request.Scopes, CurrentUserId(principal), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (result.TokenId is null)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ServiceAccountNotFound);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new CreatedServiceAccountToken(serviceAccountId, result.TokenId.Value, result.RawToken!));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, ProblemHttpResult>> RevokeTokenAsync(
|
||||
Guid tokenId, RevokeServiceAccountTokenUseCase useCase, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var revoked = await useCase.ExecuteAsync(new RevokeServiceAccountTokenCommand(tokenId, CurrentUserId(principal), correlationId), cancellationToken);
|
||||
|
||||
return revoked
|
||||
? TypedResults.Ok()
|
||||
: TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, type: ProblemTypes.ServiceAccountTokenNotFound);
|
||||
}
|
||||
|
||||
private static ServiceAccountSummary ToSummary(ServiceAccountView view) =>
|
||||
new(
|
||||
view.Account.Id, view.Account.Name, view.Account.Description, view.Account.Enabled, view.Account.CreatedAt,
|
||||
view.Tokens.Select(token => new ServiceAccountTokenSummary(
|
||||
token.Id, token.PublicId, token.Scopes, token.CreatedAt, token.LastUsedAt, token.ExpiresAt, token.RevokedAt))
|
||||
.ToList());
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Setup;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class SetupEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSetupEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/setup").WithTags("Setup");
|
||||
|
||||
group.MapGet("/status", GetStatusAsync);
|
||||
|
||||
group.MapPost("/complete", CompleteAsync)
|
||||
.AddEndpointFilter<AntiforgeryEndpointFilter>()
|
||||
.RequireRateLimiting(RateLimitPolicies.Setup);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<SetupStatusResponse>> GetStatusAsync(
|
||||
IInstallationRepository installations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var complete = await installations.IsSetupCompleteAsync(cancellationToken);
|
||||
return TypedResults.Ok(new SetupStatusResponse(!complete));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<CompleteSetupResponse>, ProblemHttpResult>> CompleteAsync(
|
||||
[FromBody] CompleteSetupRequest request,
|
||||
CompleteFirstRunSetupUseCase useCase,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
|
||||
CompleteFirstRunSetupResult result;
|
||||
try
|
||||
{
|
||||
result = await useCase.ExecuteAsync(
|
||||
new CompleteFirstRunSetupCommand(
|
||||
request.WorkspaceName,
|
||||
request.DisplayName,
|
||||
request.Email,
|
||||
request.Password,
|
||||
correlationId),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Ongeldige invoer.",
|
||||
detail: ex.Message);
|
||||
}
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
CompleteFirstRunSetupStatus.Success => TypedResults.Ok(
|
||||
new CompleteSetupResponse(result.WorkspaceId!.Value, result.UserId!.Value)),
|
||||
|
||||
CompleteFirstRunSetupStatus.AlreadyCompleted => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.SetupAlreadyCompleted,
|
||||
title: "Installatie is al voltooid.",
|
||||
detail: "De eerste installatie is al eerder afgerond."),
|
||||
|
||||
|
||||
CompleteFirstRunSetupStatus.EmailAlreadyInUse => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Ongeldige invoer.",
|
||||
detail: "Dit e-mailadres is al in gebruik."),
|
||||
|
||||
_ => TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity,
|
||||
type: ProblemTypes.Validation,
|
||||
title: "Ongeldige invoer.",
|
||||
detail: string.Join(" ", result.Errors))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Claims;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Migration;
|
||||
using ItWorx.KnowledgeHub.Application.Migration.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Migration;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit administrative purge for an externally published source batch. It accepts
|
||||
/// only a reviewed list of target page identifiers and refuses ambiguous scope before
|
||||
/// it can mutate production data.
|
||||
/// </summary>
|
||||
public static class SourceBatchPurgeEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSourceBatchPurgeEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/admin/source-batches/purge")
|
||||
.WithTags("Source batch purge")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
||||
|
||||
group.MapPost("/dry-run", DryRunAsync).AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
group.MapPost("/execute", ExecuteAsync).AddEndpointFilter<AntiforgeryEndpointFilter>();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<SourceBatchPurgeDryRunResponse>> DryRunAsync(
|
||||
[FromBody] SourceBatchPurgeRequest request,
|
||||
PurgeSourceBatchUseCase useCase,
|
||||
CancellationToken cancellationToken) =>
|
||||
TypedResults.Ok(ToResponse(await useCase.DryRunAsync(request.BatchId, request.Source, request.Pages, cancellationToken)));
|
||||
|
||||
private static async Task<Results<Ok<SourceBatchPurgeResponse>, ProblemHttpResult>> ExecuteAsync(
|
||||
[FromBody] ExecuteSourceBatchPurgeRequest request,
|
||||
PurgeSourceBatchUseCase useCase,
|
||||
ClaimsPrincipal principal,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
var result = await useCase.ExecuteAsync(
|
||||
new PurgeSourceBatchCommand(
|
||||
request.BatchId, request.Source, request.Pages, request.ConfirmationHash,
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!), correlationId),
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.Validation,
|
||||
title: result.StaleDryRun ? "Dry-run is niet langer actueel." : "Bronbatch voldoet niet aan de veiligheidscontroles.",
|
||||
detail: result.StaleDryRun
|
||||
? "Voer opnieuw een dry-run uit; de geselecteerde gegevens zijn intussen gewijzigd."
|
||||
: string.Join(" ", result.Inspection.Blockers));
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new SourceBatchPurgeResponse(
|
||||
"purged", ToResponse(result.Inspection), result.RemovedPages, result.RemovedRevisions,
|
||||
result.RemovedDrafts, result.RemovedLinks, result.RemovedAttachmentUsages,
|
||||
result.RemovedAttachmentReferences, result.RemovedImportJobs, result.RemovedImportItems,
|
||||
result.RemovedProvenanceRecords, result.RemovedBlobs));
|
||||
}
|
||||
|
||||
private static SourceBatchPurgeDryRunResponse ToResponse(SourceBatchPurgeInspection value) =>
|
||||
new(
|
||||
value.IsSafe, value.ConfirmationHash, value.PageCount, value.DescendantPageCount,
|
||||
value.RevisionCount, value.DraftCount, value.LinkCount, value.AttachmentUsageCount,
|
||||
value.AttachmentReferenceCount, value.OrphanedBlobCount, value.SharedBlobCount,
|
||||
value.ImportJobCount, value.ImportItemCount, value.ProvenanceCount,
|
||||
value.SourceTagAssignmentCount, value.SearchIndexRecordCount,
|
||||
value.GeneratedSourceDerivedPageCount, value.RetainedPageCount, value.Blockers);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
public static class TemplatesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTemplatesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/templates", ListTemplates)
|
||||
.WithTags("Templates")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireEditor);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static Ok<IReadOnlyList<TemplateSummary>> ListTemplates(ITemplateProvider templates)
|
||||
{
|
||||
IReadOnlyList<TemplateSummary> response = templates.ListTemplates()
|
||||
.Select(template => new TemplateSummary(
|
||||
template.Key, template.Name, template.DocumentType.ToString(), template.Description))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Identity;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>Interactive-only, workspace-scoped support endpoints for the web UI (not
|
||||
/// reachable by a service-account token — no automation use case has been named for
|
||||
/// these yet, and keeping them cookie-only is the narrower default per
|
||||
/// docs/adr/ADR-029-v2-r5-final-hardening.md).</summary>
|
||||
public static class WorkspaceEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapWorkspaceEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/workspace/members", SearchMembersAsync)
|
||||
.WithTags("Workspace")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireReader);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<WorkspaceMemberListResponse>> SearchMembersAsync(
|
||||
SearchWorkspaceMembersUseCase useCase,
|
||||
[FromQuery] string? q,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = await useCase.ExecuteAsync(q, cancellationToken);
|
||||
return TypedResults.Ok(new WorkspaceMemberListResponse(
|
||||
results.Select(result => new WorkspaceMemberSummary(result.Id, result.DisplayName, result.Email)).ToArray()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<AssemblyName>ItWorx.KnowledgeHub.Api</AssemblyName>
|
||||
<RootNamespace>ItWorx.KnowledgeHub.Api</RootNamespace>
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ItWorx.KnowledgeHub.Application/ItWorx.KnowledgeHub.Application.csproj" />
|
||||
<ProjectReference Include="../ItWorx.KnowledgeHub.Contracts/ItWorx.KnowledgeHub.Contracts.csproj" />
|
||||
<ProjectReference Include="../ItWorx.KnowledgeHub.Infrastructure/ItWorx.KnowledgeHub.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ItWorx.KnowledgeHub.Api.Knowledge;
|
||||
|
||||
/// <summary>HTTP ETags are quoted strings; the concurrency token we compare against
|
||||
/// (PostgreSQL's <c>xmin</c>) is a bare number. This centralizes the quoting/unquoting
|
||||
/// so every endpoint agrees on the wire format (docs/09-api-contract.md — "strong ETag
|
||||
/// plus If-Match").</summary>
|
||||
public static class ETagHelper
|
||||
{
|
||||
public static string ToHeaderValue(string etag) => $"\"{etag}\"";
|
||||
|
||||
public static string? FromHeaderValue(string? headerValue) =>
|
||||
string.IsNullOrEmpty(headerValue) ? null : headerValue.Trim().Trim('"');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Operations;
|
||||
|
||||
/// <summary>
|
||||
/// Restore runs the same deployable image in a distinct maintenance-mode invocation
|
||||
/// (<c>dotnet ItWorx.KnowledgeHub.Api.dll --restore ...</c>), never as an HTTP endpoint
|
||||
/// on the live, traffic-serving application (docs/18-backup-restore.md: "Restore is
|
||||
/// deliberately separate from normal application startup"; ADR-016 decision 4). The
|
||||
/// core logic here takes no direct dependency on <c>Environment.Exit</c>/process
|
||||
/// termination so it stays directly unit-testable; only Program.cs's thin top-level
|
||||
/// wrapper turns the returned exit code into an actual process exit.
|
||||
/// </summary>
|
||||
public static class RestoreModeRunner
|
||||
{
|
||||
public const string Flag = "--restore";
|
||||
|
||||
public static bool IsRestoreMode(string[] args) => args.Contains(Flag, StringComparer.Ordinal);
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, IServiceProvider services, TextWriter output, CancellationToken cancellationToken)
|
||||
{
|
||||
var backupId = GetArgValue(args, "--backup-id");
|
||||
var targetConnection = GetArgValue(args, "--target-connection");
|
||||
var targetAttachments = GetArgValue(args, "--target-attachments");
|
||||
|
||||
if (backupId is null || targetConnection is null || targetAttachments is null)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"Gebruik: --restore --backup-id=<id> --target-connection=<connectiereeks> --target-attachments=<pad>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var restoreService = services.GetRequiredService<IRestoreService>();
|
||||
RestoreReport report;
|
||||
try
|
||||
{
|
||||
report = await restoreService.RestoreAsync(
|
||||
backupId, new RestoreTarget(targetConnection, targetAttachments), cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
await output.WriteLineAsync($"Herstel mislukt: {exception.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
await output.WriteLineAsync($"Herstel van back-up '{report.BackupId}': {(report.Success ? "GESLAAGD" : "MISLUKT")}");
|
||||
foreach (var table in report.Tables)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
$" {table.TableName}: {table.RestoredRowCount}/{table.ExpectedRowCount} rijen{(table.Matches ? string.Empty : " (AFWIJKING)")}");
|
||||
}
|
||||
|
||||
await output.WriteLineAsync($" bijlagen: {report.AttachmentsRestored}/{report.AttachmentsExpected}");
|
||||
foreach (var warning in report.Warnings)
|
||||
{
|
||||
await output.WriteLineAsync($" WAARSCHUWING: {warning}");
|
||||
}
|
||||
|
||||
return report.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static string? GetArgValue(string[] args, string name)
|
||||
{
|
||||
var prefix = name + "=";
|
||||
var match = args.FirstOrDefault(argument => argument.StartsWith(prefix, StringComparison.Ordinal));
|
||||
return match?[prefix.Length..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Reflection;
|
||||
using System.Threading.RateLimiting;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
using ItWorx.KnowledgeHub.Api.Operations;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Attachments.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Migration.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Operations.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Projects.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Runbooks.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.SystemInfo;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Operations;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var timeProvider = TimeProvider.System;
|
||||
var startedAt = timeProvider.GetUtcNow();
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// The attachment use case accepts files up to 100 MiB. Kestrel's default request
|
||||
// limit is only about 30 MB, which would terminate valid multipart uploads before
|
||||
// they reach that policy. Keep the transport bounded while reserving one MiB for
|
||||
// multipart headers and framing around the maximum-size file.
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
options.Limits.MaxRequestBodySize = 101L * 1024 * 1024);
|
||||
|
||||
// Structured JSON logs to stdout/stderr in production for container log collection
|
||||
// (docs/19-observability.md "Logging"). IncludeScopes surfaces the correlation ID
|
||||
// CorrelationIdMiddleware attaches to every request. Framework categories are
|
||||
// deliberately quieted relative to the app's own (Information stays default for
|
||||
// everything else) — Microsoft.AspNetCore/EF Core's per-request/per-query chatter at
|
||||
// Information level would otherwise dominate the log stream without adding
|
||||
// operator value beyond what the correlation-scoped app logs already provide.
|
||||
if (builder.Environment.IsProduction())
|
||||
{
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddJsonConsole(options => options.IncludeScopes = true);
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
|
||||
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton(timeProvider);
|
||||
builder.Services.AddSingleton(_ => new Meter("ItWorx.KnowledgeHub.Ingestion"));
|
||||
builder.Services.AddSingleton<IngestionMetrics>();
|
||||
builder.Services.AddProblemDetails(options =>
|
||||
{
|
||||
options.CustomizeProblemDetails = context =>
|
||||
{
|
||||
if (context.HttpContext.Items.TryGetValue(CorrelationIdMiddleware.ItemKey, out var correlationId)
|
||||
&& correlationId is string correlationIdValue)
|
||||
{
|
||||
context.ProblemDetails.Extensions["correlationId"] = correlationIdValue;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Health checks (DatabaseHealthCheck, StorageHealthCheck) are registered inside
|
||||
// AddKnowledgeHubInfrastructure, since both depend on Infrastructure-layer types.
|
||||
builder.Services.AddKnowledgeHubInfrastructure(builder.Configuration);
|
||||
builder.Services.AddOptions<IngestionProposalOptions>()
|
||||
.BindConfiguration(IngestionProposalOptions.SectionName);
|
||||
|
||||
// Without this, ASP.NET Core falls back to an ephemeral in-memory key repository
|
||||
// (logged as a warning), which silently invalidates every session cookie, CSRF token
|
||||
// and password-reset token on every container restart/redeploy. Keys persist to a
|
||||
// dedicated mounted volume, outside the read-only application root
|
||||
// (docs/12-auth-security.md — "data-protection keys persist outside the read-only
|
||||
// root filesystem"; see docker-compose.yml's knowledge-hub-keys volume).
|
||||
var keysPath = builder.Configuration["KnowledgeHub:Paths:Keys"]
|
||||
?? Path.Combine(AppContext.BaseDirectory, "keys-data");
|
||||
Directory.CreateDirectory(keysPath);
|
||||
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(keysPath));
|
||||
|
||||
// --- Authentication (cookie) -------------------------------------------------------
|
||||
var authenticationBuilder = builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultSignInScheme = IdentityConstants.ApplicationScheme;
|
||||
});
|
||||
|
||||
// AddIdentityCookies (rather than a single AddCookie for only the application scheme)
|
||||
// also registers the External/TwoFactorRememberMe/TwoFactorUserId cookie handlers that
|
||||
// ASP.NET Core Identity's SignInManager.SignOutAsync() unconditionally signs out of —
|
||||
// even though this product does not use external logins or two-factor authentication —
|
||||
// so that a security-stamp-triggered forced sign-out does not itself throw.
|
||||
authenticationBuilder.AddIdentityCookies(identityCookies => identityCookies.ApplicationCookie!.Configure(options =>
|
||||
{
|
||||
options.Cookie.Name = "ikh_auth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
// Upgraded to effectively "Always" once trusted reverse-proxy forwarded headers
|
||||
// mark the request as HTTPS (see ForwardedHeaders configuration below).
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
// Configurable only so integration tests can prove the expiry behavior
|
||||
// deterministically and quickly, without waiting out the real 12-hour production
|
||||
// lifetime; the value is not intended to be tuned per deployment.
|
||||
var expireSeconds = builder.Configuration.GetValue<int?>("KnowledgeHub:Auth:CookieExpireSeconds");
|
||||
options.ExpireTimeSpan = expireSeconds is { } seconds
|
||||
? TimeSpan.FromSeconds(seconds)
|
||||
: TimeSpan.FromHours(12);
|
||||
options.SlidingExpiration = false;
|
||||
options.Events.OnRedirectToLogin = context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
options.Events.OnRedirectToAccessDenied = context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
// Without this, ASP.NET Core Identity's security-stamp rotation (on password reset
|
||||
// or SetEnabledAsync's UpdateSecurityStampAsync call) would only be checked lazily
|
||||
// — this wires it into the cookie pipeline so it actually runs, combined with
|
||||
// ValidationInterval = TimeSpan.Zero below for "every request" enforcement.
|
||||
options.Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync;
|
||||
}));
|
||||
|
||||
// A second, additional scheme for scoped service-account bearer tokens
|
||||
// (docs/09-api-contract.md "Service account") — registered alongside, never instead
|
||||
// of, the cookie schemes above; endpoints opt in to accepting it via
|
||||
// RequireContentAccess (ContentAccessEndpointExtensions), never as the default.
|
||||
authenticationBuilder.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, ServiceTokenAuthenticationHandler>(
|
||||
ServiceTokenAuthenticationDefaults.Scheme, _ => { });
|
||||
|
||||
// Validate the security stamp on every request (not only every N minutes) so disabling
|
||||
// a user or resetting their password invalidates existing sessions immediately.
|
||||
builder.Services.Configure<SecurityStampValidatorOptions>(options =>
|
||||
{
|
||||
options.ValidationInterval = TimeSpan.Zero;
|
||||
});
|
||||
|
||||
// --- Authorization -------------------------------------------------------------------
|
||||
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler, WorkspaceRoleAuthorizationHandler>();
|
||||
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler, ContentAccessAuthorizationHandler>();
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy(AuthorizationPolicies.RequireReader, policy =>
|
||||
policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Reader)))
|
||||
.AddPolicy(AuthorizationPolicies.RequireEditor, policy =>
|
||||
policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Editor)))
|
||||
.AddPolicy(AuthorizationPolicies.RequireAdministrator, policy =>
|
||||
policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Administrator)))
|
||||
.AddPolicy(AuthorizationPolicies.IngestionWrite, policy =>
|
||||
{
|
||||
policy.AddAuthenticationSchemes(
|
||||
IdentityConstants.ApplicationScheme,
|
||||
ServiceTokenAuthenticationDefaults.Scheme);
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.Requirements.Add(new ContentAccessRequirement(WorkspaceRole.Editor, Scopes.IngestionWrite));
|
||||
})
|
||||
.AddPolicy(AuthorizationPolicies.IngestionReview, policy =>
|
||||
{
|
||||
// Authenticate both supported schemes so a valid service token is denied with
|
||||
// 403 rather than being mistaken for an anonymous caller. Review itself is
|
||||
// still reserved for a human cookie session, including when a principal ever
|
||||
// contains claims from multiple identities.
|
||||
policy.AddAuthenticationSchemes(
|
||||
IdentityConstants.ApplicationScheme,
|
||||
ServiceTokenAuthenticationDefaults.Scheme);
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => !context.User.HasClaim(
|
||||
claim => claim.Type == ServiceTokenAuthenticationHandler.ServiceAccountClaimType));
|
||||
policy.Requirements.Add(new WorkspaceRoleRequirement(WorkspaceRole.Editor));
|
||||
});
|
||||
|
||||
// --- Anti-forgery (CSRF) ---------------------------------------------------------------
|
||||
builder.Services.AddAntiforgery(options =>
|
||||
{
|
||||
options.HeaderName = "X-CSRF-Token";
|
||||
options.Cookie.Name = "ikh_csrf";
|
||||
// Must be readable by the SPA so it can echo the value back in the header; the
|
||||
// value is not a session credential by itself (double-submit pattern).
|
||||
options.Cookie.HttpOnly = false;
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
});
|
||||
|
||||
// --- Rate limiting ---------------------------------------------------------------------
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.AddPolicy(RateLimitPolicies.Login, context =>
|
||||
CreateRemoteIpPartition(context, permitLimit: 10));
|
||||
options.AddPolicy(RateLimitPolicies.Setup, context =>
|
||||
CreateRemoteIpPartition(context, permitLimit: 5));
|
||||
options.AddPolicy(RateLimitPolicies.Recovery, context =>
|
||||
CreateRemoteIpPartition(context, permitLimit: 10));
|
||||
options.AddPolicy(RateLimitPolicies.SourceUpload, context =>
|
||||
CreateRemoteIpPartition(context, permitLimit: 5));
|
||||
});
|
||||
|
||||
// --- Use cases (composition root) -------------------------------------------------------
|
||||
builder.Services.AddScoped<CompleteFirstRunSetupUseCase>();
|
||||
builder.Services.AddScoped<LoginUseCase>();
|
||||
builder.Services.AddScoped<LogoutUseCase>();
|
||||
builder.Services.AddScoped<GetSessionUseCase>();
|
||||
builder.Services.AddScoped<IssueRecoveryTokenUseCase>();
|
||||
builder.Services.AddScoped<RedeemRecoveryTokenUseCase>();
|
||||
builder.Services.AddScoped<CreateWorkspaceUserUseCase>();
|
||||
builder.Services.AddScoped<ListWorkspaceUsersUseCase>();
|
||||
builder.Services.AddScoped<UpdateWorkspaceUserUseCase>();
|
||||
builder.Services.AddScoped<SearchWorkspaceMembersUseCase>();
|
||||
|
||||
builder.Services.AddScoped<CreatePageUseCase>();
|
||||
builder.Services.AddScoped<BeginPageCreationSessionUseCase>();
|
||||
builder.Services.AddScoped<CommitPageCreationSessionUseCase>();
|
||||
builder.Services.AddScoped<DiscardPageCreationSessionUseCase>();
|
||||
builder.Services.AddScoped<CleanupExpiredPageCreationSessionsUseCase>();
|
||||
builder.Services.AddHostedService<ItWorx.KnowledgeHub.Api.BackgroundServices.StagingSessionCleanupBackgroundService>();
|
||||
builder.Services.AddScoped<GetPageUseCase>();
|
||||
builder.Services.AddScoped<GetPageChildrenUseCase>();
|
||||
builder.Services.AddScoped<UpdatePageUseCase>();
|
||||
builder.Services.AddScoped<MovePageUseCase>();
|
||||
builder.Services.AddScoped<TrashPageUseCase>();
|
||||
builder.Services.AddScoped<RestorePageUseCase>();
|
||||
builder.Services.AddScoped<SaveDraftUseCase>();
|
||||
builder.Services.AddScoped<GetDraftUseCase>();
|
||||
builder.Services.AddScoped<DiscardDraftUseCase>();
|
||||
builder.Services.AddScoped<GetPageRevisionsUseCase>();
|
||||
builder.Services.AddScoped<GetPageRevisionUseCase>();
|
||||
builder.Services.AddScoped<RestorePageRevisionUseCase>();
|
||||
builder.Services.AddScoped<GetPageBacklinksUseCase>();
|
||||
builder.Services.AddScoped<SearchPagesUseCase>();
|
||||
builder.Services.AddScoped<GetRagPublicationManifestUseCase>();
|
||||
builder.Services.AddScoped<PurgeTrashUseCase>();
|
||||
|
||||
builder.Services.AddScoped<UploadAttachmentUseCase>();
|
||||
builder.Services.AddScoped<GetAttachmentContentUseCase>();
|
||||
builder.Services.AddScoped<GetPageAttachmentsUseCase>();
|
||||
|
||||
builder.Services.AddScoped<CreateIngestionProposalUseCase>();
|
||||
builder.Services.AddScoped<SubmitManualIngestionProposalUseCase>();
|
||||
builder.Services.AddScoped<PatchIngestionProposalUseCase>();
|
||||
builder.Services.AddScoped<ApplyIngestionProposalAnalysisUseCase>();
|
||||
builder.Services.AddScoped<DecideIngestionProposalUseCase>();
|
||||
builder.Services.AddScoped<RetryIngestionProposalUseCase>();
|
||||
builder.Services.AddScoped<StageIngestionProposalSourceUseCase>();
|
||||
builder.Services.AddScoped<PublishIngestionProposalUseCase>();
|
||||
builder.Services.AddScoped<CleanupExpiredIngestionSourcesUseCase>();
|
||||
|
||||
builder.Services.AddScoped<PreflightImportUseCase>();
|
||||
builder.Services.AddScoped<ExecuteImportUseCase>();
|
||||
builder.Services.AddScoped<RollbackImportUseCase>();
|
||||
builder.Services.AddScoped<GetImportJobUseCase>();
|
||||
builder.Services.AddScoped<PurgeSourceBatchUseCase>();
|
||||
|
||||
builder.Services.AddScoped<CreateProjectUseCase>();
|
||||
builder.Services.AddScoped<UpdateProjectUseCase>();
|
||||
builder.Services.AddScoped<ChangeProjectStatusUseCase>();
|
||||
builder.Services.AddScoped<GetProjectOverviewUseCase>();
|
||||
builder.Services.AddScoped<ListProjectsUseCase>();
|
||||
builder.Services.AddScoped<GetProjectCatalogTotalsUseCase>();
|
||||
builder.Services.AddScoped<SetProjectIconUseCase>();
|
||||
builder.Services.AddScoped<LinkProjectDocumentUseCase>();
|
||||
builder.Services.AddScoped<UnlinkProjectDocumentUseCase>();
|
||||
builder.Services.AddScoped<CreateProjectUpdateUseCase>();
|
||||
builder.Services.AddScoped<GetProjectUpdatesUseCase>();
|
||||
builder.Services.AddScoped<CreateProjectMilestoneUseCase>();
|
||||
builder.Services.AddScoped<UpdateProjectMilestoneUseCase>();
|
||||
builder.Services.AddScoped<GetProjectMilestonesUseCase>();
|
||||
builder.Services.AddScoped<GetProjectContextUseCase>();
|
||||
|
||||
builder.Services.AddScoped<CreateDecisionUseCase>();
|
||||
builder.Services.AddScoped<AcceptDecisionUseCase>();
|
||||
builder.Services.AddScoped<RejectDecisionUseCase>();
|
||||
builder.Services.AddScoped<WithdrawDecisionUseCase>();
|
||||
builder.Services.AddScoped<GetDecisionUseCase>();
|
||||
builder.Services.AddScoped<ListDecisionsUseCase>();
|
||||
|
||||
builder.Services.AddScoped<CreateRunbookUseCase>();
|
||||
builder.Services.AddScoped<GetRunbookUseCase>();
|
||||
builder.Services.AddScoped<ListRunbooksUseCase>();
|
||||
builder.Services.AddScoped<ValidateRunbookUseCase>();
|
||||
builder.Services.AddScoped<UpdateRunbookProfileUseCase>();
|
||||
|
||||
builder.Services.AddScoped<CreateServiceAccountUseCase>();
|
||||
builder.Services.AddScoped<IssueServiceAccountTokenUseCase>();
|
||||
builder.Services.AddScoped<RevokeServiceAccountTokenUseCase>();
|
||||
builder.Services.AddScoped<ListServiceAccountsUseCase>();
|
||||
builder.Services.AddScoped<AuthenticateServiceTokenUseCase>();
|
||||
builder.Services.AddSingleton<ServiceTokenFailureRateLimiter>();
|
||||
|
||||
builder.Services.AddScoped<CreateBackupUseCase>();
|
||||
builder.Services.AddScoped<ListBackupsUseCase>();
|
||||
|
||||
builder.Services.AddScoped<GetInfrastructureOverviewUseCase>();
|
||||
builder.Services.AddScoped<ListInfrastructureServicesUseCase>();
|
||||
builder.Services.AddScoped<ListInfrastructureServiceSummariesUseCase>();
|
||||
builder.Services.AddScoped<ListInfrastructureVolumesUseCase>();
|
||||
builder.Services.AddScoped<GetInfrastructureServiceUseCase>();
|
||||
builder.Services.AddScoped<ListInfrastructureChangesUseCase>();
|
||||
builder.Services.AddScoped<SynchronizeInfrastructureInventoryUseCase>();
|
||||
builder.Services.AddScoped<ItWorx.KnowledgeHub.Application.Infrastructure.Validation.EditorialProfileValidator>();
|
||||
builder.Services.AddScoped<UpdateInfrastructureEditorialProfileUseCase>();
|
||||
builder.Services.AddScoped<SetManualOverrideUseCase>();
|
||||
builder.Services.AddScoped<RemoveManualOverrideUseCase>();
|
||||
builder.Services.AddScoped<ReviewInfrastructureServiceUseCase>();
|
||||
builder.Services.AddScoped<ImportCuratedInventoryPackageUseCase>();
|
||||
|
||||
// --- Forwarded headers (reverse proxy) ---------------------------------------------------
|
||||
// Deliberately a single semicolon-delimited string, matching the existing AllowedHosts
|
||||
// convention in this same configuration surface, rather than a bound string[] array — a
|
||||
// single KnowledgeHub__TrustedProxies environment variable is far easier to wire through
|
||||
// Docker Compose than N indexed KnowledgeHub__TrustedProxies__0/__1/... entries, and no
|
||||
// production topology needs more than a couple of entries (docs/23-deployment-unraid.md
|
||||
// "Reverse proxy"). Left unset, this defaults to empty and no forwarded header is ever
|
||||
// honored — see EVIDENCE.md "Production configuration" (item 22).
|
||||
//
|
||||
// Resolved via AddOptions<T>().Configure<IConfiguration>(...) — a lazy DI factory
|
||||
// evaluated at options-resolution time — rather than reading builder.Configuration[...]
|
||||
// eagerly into a captured local here. A prior milestone hit the exact same class of bug
|
||||
// for DatabaseConnectionOptions (docs/adr/ADR-016-m7-operations-backup-and-unraid.md;
|
||||
// EVIDENCE.md "M7 — real defects"): WebApplicationFactory-based test configuration
|
||||
// overrides only attach to IConfiguration once the host is actually built, which happens
|
||||
// *after* top-level Program.cs statements like a plain eager read already ran — so an
|
||||
// eager read here would silently keep using the pre-override (empty) value in every
|
||||
// ForwardedHeadersTrustTests case, exactly like that prior bug did.
|
||||
builder.Services.AddOptions<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>()
|
||||
.Configure<Microsoft.Extensions.Configuration.IConfiguration>((options, configuration) =>
|
||||
{
|
||||
var trustedProxies = (configuration["KnowledgeHub:TrustedProxies"] ?? string.Empty)
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(entry => System.Net.IPAddress.TryParse(entry, out var address) ? address : null)
|
||||
.Where(address => address is not null)
|
||||
.Select(address => address!)
|
||||
.ToArray();
|
||||
|
||||
// ASP.NET Core's ForwardedHeadersMiddleware only enforces its KnownProxies/
|
||||
// KnownIPNetworks allow-list when that list is non-empty ("checkKnownIps"); an
|
||||
// *empty* list does not mean "trust nobody", it means "skip the IP check entirely
|
||||
// and trust every caller" — the opposite of what clearing the built-in
|
||||
// loopback-only default looks like it should do. Found by a real integration test
|
||||
// (ForwardedHeadersTrustTests) asserting the untrusted case, not by inspection: an
|
||||
// unconfigured deployment (every deployment before today, since this environment
|
||||
// variable did not previously exist) was silently honoring a self-reported
|
||||
// X-Forwarded-For/X-Forwarded-Proto from *any* direct caller — letting a client
|
||||
// spoof its own audited/rate-limited IP with no reverse proxy involved at all.
|
||||
// Fixed by disabling the middleware outright (ForwardedHeaders.None) whenever no
|
||||
// trusted proxy is configured, instead of relying on an empty allow-list.
|
||||
options.ForwardedHeaders = trustedProxies.Length > 0
|
||||
? Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
||||
: Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.None;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
|
||||
foreach (var address in trustedProxies)
|
||||
{
|
||||
options.KnownProxies.Add(address);
|
||||
}
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Maintenance-mode restore invocation of the same deployable image — never reachable
|
||||
// through the normal HTTP pipeline (docs/18-backup-restore.md; ADR-016 decision 4).
|
||||
// Exits immediately, before any web host, migration or bootstrap logic runs.
|
||||
if (RestoreModeRunner.IsRestoreMode(args))
|
||||
{
|
||||
var restoreExitCode = await RestoreModeRunner.RunAsync(args, app.Services, Console.Out, CancellationToken.None);
|
||||
Environment.Exit(restoreExitCode);
|
||||
}
|
||||
|
||||
// A second maintenance-mode invocation: apply only the schema-drift guard and
|
||||
// pending migrations against a restore target, deliberately WITHOUT the ordinary
|
||||
// startup's installation-row side effect below. docs/18-backup-restore.md
|
||||
// step 6 is "Start the recorded/current compatible application in maintenance mode"
|
||||
// specifically so the target is migrated but otherwise untouched before restore's own
|
||||
// "clean target" check runs — a normal startup lazily creates the singleton
|
||||
// installation row as a side effect of IInstallationRepository.IsSetupCompleteAsync,
|
||||
// which would then make every restore rehearsal fail that check. Found via an actual
|
||||
// restore rehearsal against a real freshly-migrated target, not by inspection.
|
||||
if (args.Contains("--migrate-only", StringComparer.Ordinal))
|
||||
{
|
||||
using (var migrateScope = app.Services.CreateScope())
|
||||
{
|
||||
var migrateDb = migrateScope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
await SchemaGuard.EnsureCompatibleAsync(migrateDb.Database, CancellationToken.None);
|
||||
await migrateDb.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
Console.Out.WriteLine("Migraties toegepast.");
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
if (!app.Environment.IsEnvironment("Test"))
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
|
||||
// M7-A02: refuse to start against a database whose applied migration history is
|
||||
// ahead of what this build knows about, before ever attempting MigrateAsync.
|
||||
await SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None);
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("IntegrationTest"))
|
||||
{
|
||||
// TestServer never simulates a real socket, so Connection.RemoteIpAddress is null
|
||||
// unless something sets it. This lets integration tests provide a deterministic
|
||||
// source address for trusted-proxy and client-scoped rate-limit checks. It is only
|
||||
// enabled in test environments.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-Test-Remote-Ip", out var testIp)
|
||||
&& System.Net.IPAddress.TryParse(testIp.ToString(), out var parsedIp))
|
||||
{
|
||||
context.Connection.RemoteIpAddress = parsedIp;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
// Must run before model binding ever reads the request body, so IdempotencyEndpointFilter
|
||||
// (which re-reads the body after argument binding has already happened) can seek back to
|
||||
// the start — EnableBuffering only makes the *first* read seekable if called before that
|
||||
// first read occurs. Guarded to requests that actually carry the header, so ordinary
|
||||
// requests are never buffered into memory unnecessarily.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey("Idempotency-Key"))
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet(
|
||||
"/health/live",
|
||||
() => Results.Ok(new
|
||||
{
|
||||
status = "healthy",
|
||||
product = "ItWorx Knowledge Hub"
|
||||
}));
|
||||
|
||||
// Deliberately no custom ResponseWriter: the default health check middleware writes
|
||||
// only the plain-text overall status ("Healthy"/"Unhealthy"), never individual check
|
||||
// descriptions or exception detail (docs/19-observability.md "/health/ready": "Return
|
||||
// minimal public details. Detailed diagnostics require administrator
|
||||
// authentication.") — see the admin-only /api/v1/system/info below for the
|
||||
// authenticated diagnostic surface.
|
||||
// Readiness follows only the Knowledge Hub's required local dependencies. Trusted
|
||||
// intake is optional, and its separate MCP deployment must not make ordinary page
|
||||
// operations unready when unavailable.
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Name is "database" or "storage"
|
||||
});
|
||||
|
||||
// Permissioned information (docs/19-observability.md "/api/v1/system/info") — unlike
|
||||
// /health/live and /health/ready (which must stay anonymous so an orchestrator can
|
||||
// probe them), this reports product/schema/version detail and therefore requires
|
||||
// administrator authentication (docs/23-deployment-unraid.md "Operational checks"
|
||||
// separately lists "authenticated system info" from the two health endpoints).
|
||||
app.MapGet(
|
||||
"/api/v1/system/info",
|
||||
async (
|
||||
IHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
ReleaseManifestReader releaseManifestReader,
|
||||
KnowledgeHubDbContext db,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var version = assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||
var commit = configuration["Build:Commit"] ?? "development";
|
||||
var locale = configuration["KnowledgeHub:PrimaryLocale"] ?? "nl-BE";
|
||||
var timeZone = configuration["KnowledgeHub:TimeZone"] ?? "Europe/Brussels";
|
||||
|
||||
var uptimeSeconds = Math.Max(
|
||||
0,
|
||||
(long)(timeProvider.GetUtcNow() - startedAt).TotalSeconds);
|
||||
|
||||
// The database migration range is the one release-identity field only the
|
||||
// *running instance itself* can know (it depends on which database it is
|
||||
// actually talking to, not on how the image was built) — computed live,
|
||||
// never baked into the static build-time manifest
|
||||
// (docs/adr/ADR-029-v2-r5-final-hardening.md).
|
||||
string? migrationRange = null;
|
||||
try
|
||||
{
|
||||
var applied = (await db.Database.GetAppliedMigrationsAsync(cancellationToken)).ToList();
|
||||
if (applied.Count > 0)
|
||||
{
|
||||
migrationRange = applied.Count == 1 ? applied[0] : $"{applied[0]}..{applied[^1]}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Never let a migration-history query failure break the whole endpoint —
|
||||
// the rest of the release identity is still worth returning.
|
||||
}
|
||||
|
||||
var manifest = releaseManifestReader.Read();
|
||||
var release = new ReleaseIdentity(
|
||||
manifest?.SemanticVersion,
|
||||
manifest?.ReleaseTag,
|
||||
manifest?.SourceCommit ?? (commit == "development" ? null : commit),
|
||||
manifest?.ImageRepository,
|
||||
manifest?.ImageTag,
|
||||
manifest?.ImageId,
|
||||
manifest?.ImageDigest,
|
||||
manifest?.BuildTimestamp,
|
||||
manifest?.DeploymentTimestamp,
|
||||
manifest?.OpenApiVersion,
|
||||
manifest?.OpenApiHash,
|
||||
manifest?.FrontendBuildHash,
|
||||
migrationRange,
|
||||
manifest?.InventorySnapshotId,
|
||||
manifest?.BackupId);
|
||||
|
||||
return Results.Ok(
|
||||
new SystemInfoResponse(
|
||||
"ItWorx Knowledge Hub",
|
||||
version,
|
||||
commit,
|
||||
"m7",
|
||||
environment.EnvironmentName,
|
||||
locale,
|
||||
timeZone,
|
||||
uptimeSeconds,
|
||||
release));
|
||||
})
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
||||
|
||||
app.MapSetupEndpoints();
|
||||
app.MapAuthEndpoints();
|
||||
app.MapAdminEndpoints();
|
||||
app.MapPagesEndpoints();
|
||||
app.MapDraftsEndpoints();
|
||||
app.MapTemplatesEndpoints();
|
||||
app.MapSearchEndpoints();
|
||||
app.MapRagPublicationEndpoints();
|
||||
app.MapAttachmentsEndpoints();
|
||||
app.MapIngestionProposalsEndpoints();
|
||||
app.MapImportsEndpoints();
|
||||
app.MapSourceBatchPurgeEndpoints();
|
||||
app.MapProjectsEndpoints();
|
||||
app.MapDecisionsEndpoints();
|
||||
app.MapRunbooksEndpoints();
|
||||
app.MapServiceAccountsEndpoints();
|
||||
app.MapBackupsEndpoints();
|
||||
app.MapInfrastructureEndpoints();
|
||||
app.MapActivityEndpoints();
|
||||
app.MapWorkspaceEndpoints();
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
|
||||
static RateLimitPartition<string> CreateRemoteIpPartition(HttpContext context, int permitLimit)
|
||||
{
|
||||
// Use the effective remote IP after UseForwardedHeaders has applied only headers
|
||||
// from configured trusted proxies. Never read X-Forwarded-For directly here.
|
||||
var partitionKey = context.Connection.RemoteIpAddress?.MapToIPv6().ToString() ?? "unknown";
|
||||
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey,
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
AutoReplenishment = true,
|
||||
PermitLimit = permitLimit,
|
||||
QueueLimit = 0,
|
||||
Window = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
}
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "http://localhost:5240",
|
||||
"applicationUrl": "http://localhost:5240",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Requires a valid anti-forgery token on cookie-authenticated mutations
|
||||
/// (docs/12-auth-security.md). Requests authenticated with a bearer token (future
|
||||
/// service-account API clients) never use cookies and are exempt, matching
|
||||
/// "Bearer-token calls do not use cookies" in docs/09-api-contract.md.
|
||||
/// </summary>
|
||||
public sealed class AntiforgeryEndpointFilter(IAntiforgery antiforgery) : IEndpointFilter
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next)
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
|
||||
if (httpContext.Request.Headers.Authorization.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(httpContext);
|
||||
}
|
||||
catch (AntiforgeryValidationException)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
type: ProblemTypes.CsrfValidationFailed,
|
||||
title: "CSRF-validatie mislukt.",
|
||||
detail: "De aanvraag ontbreekt een geldig CSRF-token.");
|
||||
}
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ItWorx.KnowledgeHub.Api.Security;
|
||||
|
||||
/// <summary>Accepts/generates <c>X-Correlation-ID</c> per docs/09-api-contract.md and
|
||||
/// makes it available to downstream code (audit events, problem details) via
|
||||
/// <see cref="HttpContext.Items"/>, and to every structured log line written while
|
||||
/// handling this request via an <see cref="ILogger"/> scope
|
||||
/// (docs/19-observability.md "Logging": "correlation/request ID" is a core log
|
||||
/// property).</summary>
|
||||
public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
|
||||
{
|
||||
public const string HeaderName = "X-Correlation-ID";
|
||||
public const string ItemKey = "CorrelationId";
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var correlationId = context.Request.Headers.TryGetValue(HeaderName, out var provided)
|
||||
&& !string.IsNullOrWhiteSpace(provided)
|
||||
? provided.ToString()
|
||||
: Guid.NewGuid().ToString();
|
||||
|
||||
context.Items[ItemKey] = correlationId;
|
||||
context.Response.Headers[HeaderName] = correlationId;
|
||||
|
||||
using var scope = logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId });
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Automation;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Honors an optional <c>Idempotency-Key</c> header on retryable create/append
|
||||
/// endpoints (docs/09-api-contract.md). A retried request with the same key from the
|
||||
/// same caller replays the original response verbatim instead of re-executing the
|
||||
/// mutation a second time. A reused key with genuinely different request content is a
|
||||
/// real conflict, not a safe retry, and is rejected.
|
||||
///
|
||||
/// Deliberately does *not* take <see cref="IIdempotencyStore"/> as a constructor
|
||||
/// parameter: <c>.AddEndpointFilter<T>()</c> constructs a DI-injected filter
|
||||
/// once, from the *application* root service provider, at route-table-build time —
|
||||
/// not per request. A scoped service (this store wraps the per-request
|
||||
/// <c>DbContext</c>) captured that way becomes a captive dependency, silently reused
|
||||
/// and corrupted across unrelated requests (found via real integration tests failing
|
||||
/// unpredictably once multiple tests ran in the same process, not by inspection).
|
||||
/// Resolving it from <c>HttpContext.RequestServices</c> inside <see cref="InvokeAsync"/>
|
||||
/// instead gets the correct, per-request-scoped instance every time.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyEndpointFilter(TimeProvider timeProvider) : IEndpointFilter
|
||||
{
|
||||
private static readonly TimeSpan Retention = TimeSpan.FromHours(24);
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
if (!httpContext.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues) || keyValues.Count == 0)
|
||||
{
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
var key = keyValues.ToString();
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length > 200)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, detail: "Ongeldige Idempotency-Key.");
|
||||
}
|
||||
|
||||
var callerId = ResolveCallerId(httpContext.User);
|
||||
if (callerId is null)
|
||||
{
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
var store = httpContext.RequestServices.GetRequiredService<IIdempotencyStore>();
|
||||
|
||||
// Minimal API argument binding ([FromBody]) already fully consumed the request
|
||||
// body by the time any endpoint filter runs — the body stream's position is at
|
||||
// the end. Program.cs's early EnableBuffering() (before routing) is what makes
|
||||
// this stream seekable at all; rewinding to the start here, *before* this
|
||||
// filter's own read, is what actually recovers the original content (a real
|
||||
// bug found via IdempotencyTests: every reused key was hashing an empty body,
|
||||
// so a conflicting retry was silently treated as an identical replay instead
|
||||
// of being rejected).
|
||||
var fingerprint = await FingerprintAsync(httpContext.Request, httpContext.RequestAborted);
|
||||
|
||||
var existing = await store.FindAsync(callerId.Value, key, httpContext.RequestAborted);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.RequestFingerprint != fingerprint)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
type: ProblemTypes.IdempotencyKeyConflict,
|
||||
title: "Idempotency-Key hergebruikt met een andere aanvraag.",
|
||||
detail: "Deze Idempotency-Key is eerder gebruikt voor een andere aanvraag-inhoud.");
|
||||
}
|
||||
|
||||
return TypedResults.Json(
|
||||
JsonSerializer.Deserialize<object>(existing.ResponseBodyJson), statusCode: existing.ResponseStatusCode);
|
||||
}
|
||||
|
||||
var result = await next(context);
|
||||
|
||||
// An endpoint that returns a union type (`Results<Ok<T>, ProblemHttpResult>`,
|
||||
// used by every V2-R1 staging endpoint and — found by this fix — the
|
||||
// pre-existing CreatePageAsync/UpdatePageAsync) hands this filter the *union
|
||||
// wrapper* instance, which itself does NOT implement IStatusCodeHttpResult —
|
||||
// confirmed empirically, not assumed, via a real integration test retrying a
|
||||
// staged-commit request: the wrapper's status/value surface only exists on the
|
||||
// concrete result it wraps, reachable through INestedHttpResult.Result. Without
|
||||
// unwrapping first, this filter silently never cached a response for any
|
||||
// union-typed endpoint, so a retried mutation could re-execute for real instead
|
||||
// of replaying the original response — no exception, no test failure anywhere
|
||||
// else, just quietly-absent idempotency.
|
||||
var concreteResult = result is INestedHttpResult nested ? nested.Result : result;
|
||||
|
||||
if (concreteResult is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } statusResult
|
||||
&& TryExtractBody(concreteResult, out var bodyJson))
|
||||
{
|
||||
var record = IdempotencyRecord.Create(
|
||||
callerId.Value, key, fingerprint, statusResult.StatusCode ?? StatusCodes.Status200OK, bodyJson,
|
||||
timeProvider.GetUtcNow(), Retention);
|
||||
await store.SaveAsync(record, httpContext.RequestAborted);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Guid? ResolveCallerId(ClaimsPrincipal principal)
|
||||
{
|
||||
var raw = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return Guid.TryParse(raw, out var id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>Reflects for a public <c>Value</c> property on an already-unwrapped
|
||||
/// concrete result (e.g. <c>Ok<T></c> — the caller unwraps any
|
||||
/// <see cref="INestedHttpResult"/> union first; see its own comment for why that
|
||||
/// matters).</summary>
|
||||
private static bool TryExtractBody(object? concrete, out string bodyJson)
|
||||
{
|
||||
bodyJson = string.Empty;
|
||||
var valueProperty = concrete?.GetType().GetProperty("Value");
|
||||
var value = valueProperty?.GetValue(concrete);
|
||||
if (value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bodyJson = JsonSerializer.Serialize(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<string> FingerprintAsync(HttpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
void Append(string value) => hash.AppendData(Encoding.UTF8.GetBytes(value));
|
||||
Append(request.Method + " " + request.Path + "\n");
|
||||
|
||||
// Multipart boundaries vary between retries. Hash the bound file metadata and
|
||||
// bytes instead, in chunks, so a 100 MB source never becomes several 100 MB
|
||||
// strings and an otherwise identical retry has the same fingerprint.
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync(cancellationToken);
|
||||
foreach (var field in form.OrderBy(entry => entry.Key, StringComparer.Ordinal))
|
||||
{
|
||||
Append("field\n" + field.Key + "\n" + field.Value + "\n");
|
||||
}
|
||||
foreach (var file in form.Files.OrderBy(file => file.Name, StringComparer.Ordinal).ThenBy(file => file.FileName, StringComparer.Ordinal))
|
||||
{
|
||||
Append("file\n" + file.Name + "\n" + file.FileName + "\n" + file.ContentType + "\n" + file.Length + "\n");
|
||||
await using var fileStream = file.OpenReadStream();
|
||||
var buffer = new byte[64 * 1024];
|
||||
int count;
|
||||
while ((count = await fileStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
hash.AppendData(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
request.EnableBuffering();
|
||||
request.Body.Position = 0;
|
||||
var buffer = new byte[64 * 1024];
|
||||
int count;
|
||||
while ((count = await request.Body.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
hash.AppendData(buffer, 0, count);
|
||||
}
|
||||
request.Body.Position = 0;
|
||||
}
|
||||
|
||||
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ItWorx.KnowledgeHub.Api.Security;
|
||||
|
||||
/// <summary>Named rate-limiting policies (docs/12-auth-security.md — separate isolated
|
||||
/// policies per sensitive area).</summary>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
public const string Setup = "setup";
|
||||
public const string Login = "login";
|
||||
public const string Recovery = "recovery";
|
||||
public const string SourceUpload = "source-upload";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ItWorx.KnowledgeHub.Api.Security;
|
||||
|
||||
/// <summary>Applies the baseline production security headers from
|
||||
/// docs/12-auth-security.md to every response. HSTS is intentionally left to the
|
||||
/// reverse proxy, which terminates TLS (see docs/23-deployment-unraid.md).</summary>
|
||||
public sealed class SecurityHeadersMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
|
||||
headers["X-Content-Type-Options"] = "nosniff";
|
||||
headers["Referrer-Policy"] = "same-origin";
|
||||
headers["X-Frame-Options"] = "DENY";
|
||||
headers["Permissions-Policy"] =
|
||||
"camera=(), microphone=(), geolocation=(), interest-cohort=()";
|
||||
headers["Content-Security-Policy"] =
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; " +
|
||||
"font-src 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'";
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Security;
|
||||
|
||||
/// <summary>
|
||||
/// A dedicated, isolated rate-limit policy for service-token authentication failures
|
||||
/// (docs/09-api-contract.md "Rate limits": "service-token authentication failures" is
|
||||
/// its own named policy, separate from the general API limit) — keyed per client IP so
|
||||
/// one caller repeatedly presenting an invalid/revoked token cannot exhaust a shared
|
||||
/// budget for every other legitimate caller.
|
||||
/// </summary>
|
||||
public sealed class ServiceTokenFailureRateLimiter : IDisposable
|
||||
{
|
||||
private readonly PartitionedRateLimiter<string> _limiter = PartitionedRateLimiter.Create<string, string>(
|
||||
clientIp => RateLimitPartition.GetFixedWindowLimiter(
|
||||
clientIp,
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 20,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0
|
||||
}));
|
||||
|
||||
public bool TryAcquireOnFailure(string clientIp) => _limiter.AttemptAcquire(clientIp).IsAcquired;
|
||||
|
||||
public void Dispose() => _limiter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"AllowedHosts": "*",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"KnowledgeHub": {
|
||||
"PrimaryLocale": "nl-BE",
|
||||
"TimeZone": "Europe/Brussels",
|
||||
"BaseUrl": "http://localhost:1240"
|
||||
},
|
||||
"Build": {
|
||||
"Commit": "development"
|
||||
},
|
||||
"AllowedHosts": "localhost;127.0.0.1;[::1]",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="nl-BE">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>ItWorx Knowledge Hub</title>
|
||||
<style>
|
||||
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: #f5f7f8; color: #172126; }
|
||||
main { max-width: 760px; margin: 10vh auto; padding: 2rem; }
|
||||
section { background: white; border: 1px solid #d8e0e3; border-radius: 14px; padding: 2rem; }
|
||||
code { background: #eef2f3; border-radius: 4px; padding: .15rem .35rem; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #111719; color: #edf4f4; }
|
||||
section { background: #172126; border-color: #314044; }
|
||||
code { background: #273337; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section>
|
||||
<p>ItWorx</p>
|
||||
<h1>Knowledge Hub</h1>
|
||||
<p>De technische baseline draait. Claude bouwt vanaf M1 de volledige applicatie-interface.</p>
|
||||
<p>Controle: <code>/health/live</code> en <code>/api/v1/system/info</code>.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ItWorx.KnowledgeHub.Application;
|
||||
|
||||
public static class AssemblyMarker
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments;
|
||||
|
||||
/// <summary>
|
||||
/// One process-wide, per-content-hash mutual-exclusion lock, shared by every writer of
|
||||
/// the content-addressed blob store — <c>UploadAttachmentUseCase</c> (commit a new blob
|
||||
/// or discover an existing one) and <c>OrphanedBlobGarbageCollector</c> (delete a blob
|
||||
/// no reference points to any more). Registered as a singleton so both use cases,
|
||||
/// resolved from different per-request scopes, serialize against the very same lock
|
||||
/// table.
|
||||
///
|
||||
/// This is what makes blob garbage collection safe against a concurrent upload of
|
||||
/// byte-identical content: without it, a GC pass could delete a blob's database row and
|
||||
/// physical file in between an upload's own "does this hash already exist?" check and
|
||||
/// its own write, corrupting the upload's result. With both critical sections holding
|
||||
/// the same per-hash lock, one of two outcomes is always consistent — either GC
|
||||
/// completes first (the upload then behaves exactly like any fresh upload of new
|
||||
/// content), or the upload completes first (GC's own re-check-under-lock then correctly
|
||||
/// finds a live reference and skips deletion).
|
||||
/// </summary>
|
||||
public sealed class AttachmentContentLock
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, Entry> locks = [];
|
||||
|
||||
public async Task<IDisposable> AcquireAsync(string sha256, CancellationToken cancellationToken)
|
||||
{
|
||||
Entry entry;
|
||||
lock (gate)
|
||||
{
|
||||
if (!locks.TryGetValue(sha256, out entry!))
|
||||
{
|
||||
entry = new Entry();
|
||||
locks.Add(sha256, entry);
|
||||
}
|
||||
|
||||
entry.ReferenceCount += 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await entry.Semaphore.WaitAsync(cancellationToken);
|
||||
return new Lease(sha256, entry, this);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Release(sha256, entry, acquired: false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void Release(string sha256, Entry entry, bool acquired)
|
||||
{
|
||||
if (acquired)
|
||||
{
|
||||
entry.Semaphore.Release();
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
entry.ReferenceCount -= 1;
|
||||
if (entry.ReferenceCount == 0 && locks.TryGetValue(sha256, out var current) && ReferenceEquals(current, entry))
|
||||
{
|
||||
locks.Remove(sha256);
|
||||
entry.Semaphore.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public SemaphoreSlim Semaphore { get; } = new(1, 1);
|
||||
|
||||
public int ReferenceCount { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Lease(string sha256, Entry entry, AttachmentContentLock owner) : IDisposable
|
||||
{
|
||||
private bool disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
owner.Release(sha256, entry, acquired: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments;
|
||||
|
||||
public sealed record QuarantinedUpload(string QuarantineToken, string Sha256, long ByteLength);
|
||||
|
||||
/// <summary>
|
||||
/// Physical content-addressed storage for attachment blobs
|
||||
/// (docs/13-attachments.md — quarantine-then-atomic-move upload pipeline). The
|
||||
/// physical path is always derived from the content hash alone, never from any
|
||||
/// caller-supplied filename.
|
||||
/// </summary>
|
||||
public interface IAttachmentBlobStore
|
||||
{
|
||||
/// <summary>Streams <paramref name="content"/> once into a same-filesystem
|
||||
/// quarantine location while computing its SHA-256 and byte length, so a
|
||||
/// subsequent commit can be a fast atomic move rather than a second full copy.
|
||||
/// </summary>
|
||||
Task<QuarantinedUpload> WriteToQuarantineAsync(Stream content, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads a bounded prefix of the quarantined file for signature
|
||||
/// detection, without loading the whole file into memory.</summary>
|
||||
Task<byte[]> ReadQuarantinePrefixAsync(string quarantineToken, int maxBytes, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when a blob with this hash is already stored — the caller should
|
||||
/// discard the quarantined duplicate rather than move it
|
||||
/// (docs/13-attachments.md — deduplication).</summary>
|
||||
Task<bool> BlobExistsAsync(string sha256, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Atomically moves a quarantined file into its final content-addressed
|
||||
/// location. The caller has already confirmed no blob with this hash exists.
|
||||
/// </summary>
|
||||
Task CommitAsync(string quarantineToken, string sha256, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Removes a quarantined file that will not be committed — every
|
||||
/// failure/cancellation path must call this (docs/13-attachments.md).</summary>
|
||||
Task DiscardQuarantineAsync(string quarantineToken, CancellationToken cancellationToken);
|
||||
|
||||
Task<Stream> OpenReadAsync(string sha256, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Verifies physical bytes against their content address and removes the blob only on a match.</summary>
|
||||
Task<bool> DeleteVerifiedAsync(string sha256, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments;
|
||||
|
||||
/// <summary>Result of orphan-removal for one staging session's attachment usages
|
||||
/// (see <see cref="IAttachmentRepository.RemoveOrphanedStagingAttachmentsAsync"/>).
|
||||
/// <see cref="CandidateBlobHashes"/> are the content hashes of every
|
||||
/// <see cref="AttachmentReference"/> row removed by that call — each is a *candidate*
|
||||
/// for physical blob garbage collection only, not yet confirmed unreferenced
|
||||
/// workspace-wide (a second, possibly identical, upload elsewhere may still hold a
|
||||
/// live reference to the same hash) — see <c>OrphanedBlobGarbageCollector</c>.</summary>
|
||||
public sealed record StagingAttachmentCleanupResult(int ReferencesRemoved, IReadOnlyList<string> CandidateBlobHashes)
|
||||
{
|
||||
public static readonly StagingAttachmentCleanupResult None = new(0, []);
|
||||
}
|
||||
|
||||
public sealed record ProposalSourceAttachmentCleanupResult(bool UsageRemoved, bool ReferenceRemoved)
|
||||
{
|
||||
public static readonly ProposalSourceAttachmentCleanupResult None = new(false, false);
|
||||
}
|
||||
|
||||
public interface IAttachmentRepository
|
||||
{
|
||||
Task<AttachmentBlob?> FindBlobAsync(string sha256, CancellationToken cancellationToken);
|
||||
|
||||
void AddBlob(AttachmentBlob blob);
|
||||
|
||||
Task<AttachmentReference?> FindReferenceAsync(Guid referenceId, CancellationToken cancellationToken);
|
||||
|
||||
void AddReference(AttachmentReference reference);
|
||||
|
||||
void AddUsage(AttachmentUsage usage);
|
||||
|
||||
/// <summary>Project-owned icon usages, tracked so icon rotation can retire prior
|
||||
/// references in the same unit of work as the project pointer change.</summary>
|
||||
Task<IReadOnlyList<AttachmentUsage>> GetUsagesForProjectAsync(Guid projectId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<AttachmentUsage>>([]);
|
||||
|
||||
Task<bool> HasOtherUsagesAsync(Guid attachmentReferenceId, Guid excludingUsageId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
void RemoveUsage(AttachmentUsage usage) => throw new NotSupportedException();
|
||||
|
||||
void RemoveReference(AttachmentReference reference) => throw new NotSupportedException();
|
||||
|
||||
Task<IReadOnlyList<AttachmentReference>> GetReferencesForPageAsync(Guid pageId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyDictionary<string, AttachmentBlob>> FindBlobsAsync(IReadOnlyCollection<string> sha256Hashes, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The single owning usage row for a logical reference (page/staging-session
|
||||
/// ownership is always exactly one row per reference — see <see cref="AttachmentUsage"/>).
|
||||
/// Used by <c>GetAttachmentContentUseCase</c> to decide whether a staged (not yet
|
||||
/// published) attachment's stricter authorization applies. <c>null</c> for an
|
||||
/// orphaned reference with no usage at all (never served).</summary>
|
||||
Task<AttachmentUsage?> FindUsageForReferenceAsync(Guid attachmentReferenceId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Of the given candidate content hashes, the subset with zero remaining
|
||||
/// <see cref="AttachmentReference"/> rows anywhere in the system — safe to physically
|
||||
/// garbage-collect. Always re-check immediately before deleting (never cache), and
|
||||
/// always under <c>AttachmentContentLock</c> for the hash, to close the
|
||||
/// concurrent-upload-of-identical-content race.</summary>
|
||||
Task<IReadOnlyList<string>> FindUnreferencedBlobHashesAsync(IReadOnlyCollection<string> candidateHashes, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Removes the given <see cref="AttachmentBlob"/> rows. The caller is
|
||||
/// responsible for having already confirmed (under lock) that no reference remains
|
||||
/// and for the corresponding physical-file deletion.</summary>
|
||||
Task<int> RemoveBlobRecordsAsync(IReadOnlyCollection<string> sha256Hashes, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Every usage still owned by an open page-creation session, *tracked*
|
||||
/// (not <c>AsNoTracking</c>) so <c>CommitPageCreationSessionUseCase</c> can call
|
||||
/// <see cref="AttachmentUsage.AttachToPage"/> on each and have the resulting
|
||||
/// UPDATEs ride the same <c>SaveChangesAsync</c> transaction as the new page's own
|
||||
/// INSERT — the atomicity guarantee at the center of the staged-creation design
|
||||
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md).</summary>
|
||||
Task<IReadOnlyList<AttachmentUsage>> GetUsagesForStagingSessionAsync(Guid stagingSessionId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Stages the ownership transfer of every source belonging to a published
|
||||
/// proposal. The caller's aggregate save commits these tracked changes atomically
|
||||
/// with the page, revision, proposal, audit record and outbox event.</summary>
|
||||
Task TransferProposalUsagesToPageAsync(Guid ingestionProposalId, Guid pageId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Stages deletion of one stale, still unpublished proposal-owned usage.
|
||||
/// The repository rechecks every ownership predicate at execution time and removes
|
||||
/// its reference only if no other usage remains. It never removes physical blobs.</summary>
|
||||
Task<ProposalSourceAttachmentCleanupResult> RemoveExpiredProposalSourceUsageAsync(
|
||||
IngestionSourceRetentionCandidate source,
|
||||
CancellationToken cancellationToken) => Task.FromResult(ProposalSourceAttachmentCleanupResult.None);
|
||||
|
||||
/// <summary>Compensation for a discarded (or expired) staging session: removes
|
||||
/// every usage still pointing at it, then removes any attachment reference left
|
||||
/// with no remaining usage at all — the same two-step orphan-removal shape as
|
||||
/// <c>IPageRollbackRepository.RemoveOrphanedImportedAttachmentsAsync</c>. Executes
|
||||
/// immediately (bulk <c>ExecuteDeleteAsync</c>), independent of the caller's own
|
||||
/// <see cref="SaveChangesAsync"/> — deliberately not required to be atomic with
|
||||
/// marking the session discarded (an orphaned-but-undiscarded session is safely
|
||||
/// retryable and never a data-loss risk; the physical blob is never touched here,
|
||||
/// matching <c>AttachmentUsage</c>'s "orphan cleanup follows retention
|
||||
/// separately" — see <c>OrphanedBlobGarbageCollector</c> for the actual physical
|
||||
/// cleanup, driven by the returned <see cref="StagingAttachmentCleanupResult.CandidateBlobHashes"/>).
|
||||
/// </summary>
|
||||
Task<StagingAttachmentCleanupResult> RemoveOrphanedStagingAttachmentsAsync(Guid stagingSessionId, CancellationToken cancellationToken);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments;
|
||||
|
||||
public sealed record SignatureDetectionResult(
|
||||
string DetectedMediaType,
|
||||
AttachmentDeliveryPolicy DeliveryPolicy,
|
||||
bool DeclaredTypeMismatch);
|
||||
|
||||
/// <summary>
|
||||
/// Detects a file's real content type from its byte signature rather than trusting the
|
||||
/// caller-declared MIME type or file extension alone
|
||||
/// (docs/13-attachments.md — "detect known signatures", "compare declared type,
|
||||
/// extension and detected type").
|
||||
/// </summary>
|
||||
public interface IContentSignatureDetector
|
||||
{
|
||||
SignatureDetectionResult Detect(ReadOnlySpan<byte> prefix, string? declaredContentType, string? extension);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments;
|
||||
|
||||
/// <summary>
|
||||
/// Safe, lock-serialized physical deletion of attachment blobs that have become
|
||||
/// unreferenced — used by both the explicit "discard a staging session" flow
|
||||
/// (<c>DiscardPageCreationSessionUseCase</c>) and the automatic expired-session cleanup
|
||||
/// worker (<c>CleanupExpiredPageCreationSessionsUseCase</c>). Never touches a blob that
|
||||
/// might still be shared by another, unrelated <c>AttachmentReference</c> — the whole
|
||||
/// point of content-addressed deduplication is that one physical file can back many
|
||||
/// logical references, so "this session's staged reference is gone" is only ever a
|
||||
/// *candidate* for deletion, re-verified here under <see cref="AttachmentContentLock"/>
|
||||
/// immediately before acting.
|
||||
/// </summary>
|
||||
public sealed class OrphanedBlobGarbageCollector(
|
||||
IAttachmentRepository attachments, IAttachmentBlobStore blobStore, AttachmentContentLock contentLock)
|
||||
{
|
||||
/// <summary>Returns the number of blobs actually physically removed. A blob whose
|
||||
/// file was already missing on disk still has its now-pointless database row
|
||||
/// removed (treated as removed for reporting purposes) — never left dangling
|
||||
/// forever just because an earlier partial run already deleted the bytes.</summary>
|
||||
public async Task<int> CollectAsync(IReadOnlyCollection<string> candidateHashes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (candidateHashes.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var removed = 0;
|
||||
foreach (var hash in candidateHashes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
using var lease = await contentLock.AcquireAsync(hash, cancellationToken);
|
||||
|
||||
// Re-check under the lock: the candidate list was computed before this lock
|
||||
// was acquired, and a concurrent upload of the exact same bytes may have
|
||||
// created a brand-new live reference to this hash in the meantime.
|
||||
var stillUnreferenced = await attachments.FindUnreferencedBlobHashesAsync([hash], cancellationToken);
|
||||
if (stillUnreferenced.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Database truth first (matches the established PurgeSourceBatchUseCase
|
||||
// ordering): if the process crashes between these two calls, the worst case
|
||||
// is an orphaned file on disk with no database row pointing at it — safe,
|
||||
// recoverable by a future disk-level sweep, never a database row pointing at
|
||||
// bytes that no longer exist.
|
||||
await attachments.RemoveBlobRecordsAsync([hash], cancellationToken);
|
||||
|
||||
// Tolerates an already-missing file (a previous run's crash-after-DB-delete,
|
||||
// or the file was never actually written) — DeleteVerifiedAsync returns
|
||||
// false rather than throwing.
|
||||
await blobStore.DeleteVerifiedAsync(hash, cancellationToken);
|
||||
removed++;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Ingestion;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments.UseCases;
|
||||
|
||||
public sealed record AttachmentContentResult(
|
||||
AttachmentReference Reference,
|
||||
AttachmentBlob Blob,
|
||||
Stream Content);
|
||||
|
||||
/// <summary>Who is asking for an attachment's content — resolved once by the endpoint
|
||||
/// (from the authenticated principal already required by <c>RequireContentAccess</c>)
|
||||
/// and passed in, so this use case's authorization decision is independently testable
|
||||
/// from the HTTP layer and does not silently trust endpoint routing alone.</summary>
|
||||
public sealed record AttachmentRequestor(Guid? UserId, WorkspaceRole? Role)
|
||||
{
|
||||
/// <summary>A service-account (bearer-token) caller. Service accounts are never
|
||||
/// granted access to content held by an open page-creation session or an unpublished
|
||||
/// ingestion proposal. Ordinary, already-published attachments remain reachable
|
||||
/// exactly as before.</summary>
|
||||
public bool IsAdministrator => Role == WorkspaceRole.Administrator;
|
||||
|
||||
public static readonly AttachmentRequestor ServiceAccount = new(null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every content request re-authorizes the logical reference
|
||||
/// (docs/13-attachments.md — "serving"): always invoked behind the same Reader
|
||||
/// authorization as every other read endpoint, refuses a trashed reference, and — since
|
||||
/// V2-R5 — refuses a reference that is still owned by an open page-creation staging
|
||||
/// session unless the requestor is that session's own creator or a workspace
|
||||
/// Administrator. An unpublished ingestion source is private to its submitter and
|
||||
/// administrators while it is Received or ChangesRequested, and is readable by
|
||||
/// Editors+ during review. Before the staging-session fix, a workspace Reader who
|
||||
/// merely learned or guessed a staged attachment's GUID could read it before the page
|
||||
/// it belongs to was published (docs/adr/ADR-029-v2-r5-final-hardening.md finding
|
||||
/// V2-R5-H1) — a content hash or reference GUID alone is never sufficient permission
|
||||
/// to retrieve a blob.
|
||||
/// </summary>
|
||||
public sealed class GetAttachmentContentUseCase(
|
||||
IAttachmentRepository attachments,
|
||||
IAttachmentBlobStore blobStore,
|
||||
IPageCreationSessionRepository stagingSessions,
|
||||
IIngestionProposalRepository proposals,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<AttachmentContentResult?> ExecuteAsync(
|
||||
Guid workspaceId, Guid referenceId, AttachmentRequestor requestor, CancellationToken cancellationToken)
|
||||
{
|
||||
var reference = await attachments.FindReferenceAsync(referenceId, cancellationToken);
|
||||
if (reference is null || reference.IsTrashed || reference.WorkspaceId != workspaceId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var usage = await attachments.FindUsageForReferenceAsync(referenceId, cancellationToken);
|
||||
if (usage is null)
|
||||
{
|
||||
// An orphaned reference with no owner at all should never exist in steady
|
||||
// state, but never serve one if it somehow does.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (usage.StagingSessionId is { } stagingSessionId && !await IsAuthorizedForStagedAttachmentAsync(
|
||||
workspaceId, stagingSessionId, requestor, cancellationToken))
|
||||
{
|
||||
// Deliberately the same 404 shape as "does not exist" — an unauthorized
|
||||
// caller learns nothing about whether a staged attachment with this GUID
|
||||
// exists at all.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (usage.IngestionProposalId is { } proposalId && !await IsAuthorizedForProposalAttachmentAsync(
|
||||
workspaceId, proposalId, requestor, cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blob = await attachments.FindBlobAsync(reference.BlobSha256, cancellationToken);
|
||||
if (blob is null || blob.StorageState is AttachmentStorageState.Missing or AttachmentStorageState.Corrupt)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var stream = await blobStore.OpenReadAsync(blob.Sha256, cancellationToken);
|
||||
return new AttachmentContentResult(reference, blob, stream);
|
||||
}
|
||||
|
||||
private async Task<bool> IsAuthorizedForStagedAttachmentAsync(
|
||||
Guid workspaceId, Guid stagingSessionId, AttachmentRequestor requestor, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestor.UserId is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = await stagingSessions.FindAsync(workspaceId, stagingSessionId, cancellationToken);
|
||||
if (session is null || !session.IsOpen || session.IsExpired(timeProvider.GetUtcNow()))
|
||||
{
|
||||
// The owning session was since discarded/committed, or has expired but the
|
||||
// background cleanup worker has not yet reclaimed it — treated the same as
|
||||
// "closed" either way (matching CommitPageCreationSessionUseCase's own
|
||||
// !session.IsOpen || session.IsExpired(now) check), never served.
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.CreatedBy == requestor.UserId.Value || requestor.IsAdministrator;
|
||||
}
|
||||
|
||||
private async Task<bool> IsAuthorizedForProposalAttachmentAsync(
|
||||
Guid workspaceId, Guid proposalId, AttachmentRequestor requestor, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestor.UserId is null || requestor.Role is null)
|
||||
{
|
||||
// Service tokens have attachment-read scope for published content, but
|
||||
// unpublished proposal sources remain human-controlled review material.
|
||||
return false;
|
||||
}
|
||||
|
||||
var proposal = await proposals.FindAsync(workspaceId, proposalId, cancellationToken);
|
||||
if (proposal is null)
|
||||
{
|
||||
// Never serve an attachment whose proposal owner cannot be confirmed.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (proposal.Status is IngestionProposalStatus.Received or IngestionProposalStatus.ChangesRequested)
|
||||
{
|
||||
return requestor.UserId == proposal.SubmittedByUserId || requestor.IsAdministrator;
|
||||
}
|
||||
|
||||
return requestor.Role is WorkspaceRole.Editor or WorkspaceRole.Administrator;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments.UseCases;
|
||||
|
||||
public sealed record PageAttachmentResult(AttachmentReference Reference, AttachmentBlob? Blob);
|
||||
|
||||
public sealed class GetPageAttachmentsUseCase(IAttachmentRepository attachments)
|
||||
{
|
||||
public async Task<IReadOnlyList<PageAttachmentResult>> ExecuteAsync(Guid pageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var references = await attachments.GetReferencesForPageAsync(pageId, cancellationToken);
|
||||
var blobs = await attachments.FindBlobsAsync(
|
||||
references.Select(reference => reference.BlobSha256).Distinct().ToList(), cancellationToken);
|
||||
|
||||
return references
|
||||
.Select(reference => new PageAttachmentResult(reference, blobs.GetValueOrDefault(reference.BlobSha256)))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Application.Projects;
|
||||
using ItWorx.KnowledgeHub.Domain.Attachments;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Attachments.UseCases;
|
||||
|
||||
public enum UploadAttachmentStatus
|
||||
{
|
||||
Success,
|
||||
PageNotFound,
|
||||
StagingSessionNotFound,
|
||||
IngestionProposalNotFound,
|
||||
ProjectNotFound,
|
||||
TooLarge,
|
||||
EmptyFile,
|
||||
InvalidFilename,
|
||||
Rejected
|
||||
}
|
||||
|
||||
public enum AttachmentOwnerKind
|
||||
{
|
||||
Page,
|
||||
StagingSession,
|
||||
IngestionProposal,
|
||||
Project
|
||||
}
|
||||
|
||||
/// <summary>An attachment upload targets exactly one owner: either an existing,
|
||||
/// already-published page, an open page-creation staging session, an ingestion
|
||||
/// proposal awaiting review, or a project icon
|
||||
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md). Both paths share the same
|
||||
/// quarantine/hash/signature/dedup/commit pipeline below — only which
|
||||
/// <see cref="AttachmentUsage"/> gets created differs.</summary>
|
||||
public sealed record AttachmentOwner(AttachmentOwnerKind Kind, Guid Id)
|
||||
{
|
||||
public static AttachmentOwner ForPage(Guid pageId) => new(AttachmentOwnerKind.Page, pageId);
|
||||
|
||||
public static AttachmentOwner ForStagingSession(Guid stagingSessionId) => new(AttachmentOwnerKind.StagingSession, stagingSessionId);
|
||||
|
||||
public static AttachmentOwner ForIngestionProposal(Guid proposalId) => new(AttachmentOwnerKind.IngestionProposal, proposalId);
|
||||
|
||||
public static AttachmentOwner ForProject(Guid projectId) => new(AttachmentOwnerKind.Project, projectId);
|
||||
}
|
||||
|
||||
public sealed record UploadAttachmentCommand(
|
||||
Guid WorkspaceId,
|
||||
AttachmentOwner Owner,
|
||||
string DeclaredFilename,
|
||||
string? DeclaredContentType,
|
||||
Stream Content,
|
||||
Guid ActorId,
|
||||
string? CorrelationId,
|
||||
bool DeferPersistence = false,
|
||||
long? MaximumUploadBytes = null,
|
||||
IReadOnlySet<string>? AllowedMediaTypes = null);
|
||||
|
||||
public sealed record UploadAttachmentResult(
|
||||
UploadAttachmentStatus Status,
|
||||
Guid? ReferenceId,
|
||||
string? StoredFilename,
|
||||
string? DetectedMediaType,
|
||||
AttachmentDeliveryPolicy? DeliveryPolicy,
|
||||
long? ByteLength,
|
||||
string? BlobSha256,
|
||||
bool NewBlobCommitted,
|
||||
string? Detail)
|
||||
{
|
||||
public static UploadAttachmentResult Success(
|
||||
Guid referenceId, string storedFilename, string detectedMediaType, AttachmentDeliveryPolicy deliveryPolicy, long byteLength,
|
||||
string blobSha256, bool newBlobCommitted) =>
|
||||
new(UploadAttachmentStatus.Success, referenceId, storedFilename, detectedMediaType, deliveryPolicy, byteLength,
|
||||
blobSha256, newBlobCommitted, null);
|
||||
|
||||
public static UploadAttachmentResult Failure(UploadAttachmentStatus status, string? detail = null) =>
|
||||
new(status, null, null, null, null, null, null, false, detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The upload pipeline from docs/13-attachments.md: stream once to quarantine while
|
||||
/// hashing, verify size, detect the real signature, apply delivery policy, then
|
||||
/// atomically commit the blob before creating the logical reference and page usage in
|
||||
/// one transaction. Every failure path discards the quarantined file
|
||||
/// (docs/13-attachments.md — "remove quarantine data on every failure/cancellation").
|
||||
/// </summary>
|
||||
public sealed class UploadAttachmentUseCase(
|
||||
IPageRepository pages,
|
||||
IPageCreationSessionRepository stagingSessions,
|
||||
IIngestionProposalRepository ingestionProposals,
|
||||
IProjectRepository projects,
|
||||
IAttachmentRepository attachments,
|
||||
IAttachmentBlobStore blobStore,
|
||||
IContentSignatureDetector signatureDetector,
|
||||
IAuditEventRecorder auditEvents,
|
||||
AttachmentContentLock contentLock,
|
||||
TimeProvider timeProvider,
|
||||
long maximumUploadBytes = 100 * 1024 * 1024)
|
||||
{
|
||||
public async Task<UploadAttachmentResult> ExecuteAsync(UploadAttachmentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.Owner.Kind == AttachmentOwnerKind.Page)
|
||||
{
|
||||
var page = await pages.FindAsync(command.Owner.Id, cancellationToken);
|
||||
if (page is null)
|
||||
{
|
||||
return UploadAttachmentResult.Failure(UploadAttachmentStatus.PageNotFound);
|
||||
}
|
||||
}
|
||||
else if (command.Owner.Kind == AttachmentOwnerKind.StagingSession)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var session = await stagingSessions.FindAsync(command.WorkspaceId, command.Owner.Id, cancellationToken);
|
||||
if (session is null || !session.IsOpen || session.IsExpired(now))
|
||||
{
|
||||
return UploadAttachmentResult.Failure(UploadAttachmentStatus.StagingSessionNotFound);
|
||||
}
|
||||
}
|
||||
else if (command.Owner.Kind == AttachmentOwnerKind.IngestionProposal)
|
||||
{
|
||||
var proposal = await ingestionProposals.FindAsync(command.WorkspaceId, command.Owner.Id, cancellationToken);
|
||||
if (proposal is null)
|
||||
{
|
||||
return UploadAttachmentResult.Failure(UploadAttachmentStatus.IngestionProposalNotFound);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var project = await projects.FindAsync(command.Owner.Id, cancellationToken);
|
||||
if (project is null || project.WorkspaceId != command.WorkspaceId)
|
||||
{
|
||||
return UploadAttachmentResult.Failure(UploadAttachmentStatus.ProjectNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
SafeFilename filename;
|
||||
try
|
||||
{
|
||||
filename = SafeFilename.Create(command.DeclaredFilename);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return UploadAttachmentResult.Failure(UploadAttachmentStatus.InvalidFilename, ex.Message);
|
||||
}
|
||||
|
||||
var quarantined = await blobStore.WriteToQuarantineAsync(command.Content, cancellationToken);
|
||||
var committed = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (quarantined.ByteLength == 0)
|
||||
{
|
||||
return UploadAttachmentResult.Failure(UploadAttachmentStatus.EmptyFile);
|
||||
}
|
||||
|
||||
var uploadLimit = Math.Min(maximumUploadBytes, command.MaximumUploadBytes ?? maximumUploadBytes);
|
||||
if (quarantined.ByteLength > uploadLimit)
|
||||
{
|
||||
return UploadAttachmentResult.Failure(
|
||||
UploadAttachmentStatus.TooLarge,
|
||||
$"Bestand is groter dan de toegestane {uploadLimit / (1024 * 1024)} MiB.");
|
||||
}
|
||||
|
||||
var prefix = await blobStore.ReadQuarantinePrefixAsync(quarantined.QuarantineToken, 4096, cancellationToken);
|
||||
var signature = signatureDetector.Detect(prefix, command.DeclaredContentType, filename.LastExtension());
|
||||
|
||||
if (signature.DeliveryPolicy == AttachmentDeliveryPolicy.Rejected)
|
||||
{
|
||||
return UploadAttachmentResult.Failure(
|
||||
UploadAttachmentStatus.Rejected,
|
||||
$"Bestandstype '{signature.DetectedMediaType}' wordt niet toegestaan als bijlage.");
|
||||
}
|
||||
|
||||
if (command.AllowedMediaTypes is { } allowedMediaTypes && !allowedMediaTypes.Contains(signature.DetectedMediaType))
|
||||
{
|
||||
return UploadAttachmentResult.Failure(
|
||||
UploadAttachmentStatus.Rejected,
|
||||
$"Bestandstype '{signature.DetectedMediaType}' wordt voor dit gebruik niet toegestaan.");
|
||||
}
|
||||
|
||||
// Deduplication spans both the filesystem commit and the database blob
|
||||
// row. Serialize that small critical section per content hash so two
|
||||
// concurrent requests cannot both observe "missing", race the atomic
|
||||
// rename and then race the attachment_blobs primary key. Unrelated hashes
|
||||
// remain fully concurrent; idle lock entries are removed safely.
|
||||
using var contentLease = await contentLock.AcquireAsync(quarantined.Sha256, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var existingBlob = await attachments.FindBlobAsync(quarantined.Sha256, cancellationToken);
|
||||
|
||||
if (existingBlob is null)
|
||||
{
|
||||
await blobStore.CommitAsync(quarantined.QuarantineToken, quarantined.Sha256, cancellationToken);
|
||||
committed = true;
|
||||
attachments.AddBlob(AttachmentBlob.Create(
|
||||
quarantined.Sha256, quarantined.ByteLength, signature.DetectedMediaType, signature.DeliveryPolicy, now));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Deduplication (docs/13-attachments.md): reuse the existing blob,
|
||||
// never store the same content twice — the quarantined duplicate is
|
||||
// discarded in the `finally` block below like any other non-committed
|
||||
// quarantine file.
|
||||
}
|
||||
|
||||
var reference = AttachmentReference.Create(command.WorkspaceId, quarantined.Sha256, filename.Value, command.ActorId, now);
|
||||
attachments.AddReference(reference);
|
||||
attachments.AddUsage(command.Owner.Kind switch
|
||||
{
|
||||
AttachmentOwnerKind.Page => AttachmentUsage.ForPage(reference.Id, command.Owner.Id, now),
|
||||
AttachmentOwnerKind.StagingSession => AttachmentUsage.ForStagingSession(reference.Id, command.Owner.Id, now),
|
||||
AttachmentOwnerKind.IngestionProposal => AttachmentUsage.ForIngestionProposal(reference.Id, command.Owner.Id, now),
|
||||
AttachmentOwnerKind.Project => AttachmentUsage.ForProject(reference.Id, command.Owner.Id, now),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(command))
|
||||
});
|
||||
|
||||
if (!command.DeferPersistence)
|
||||
{
|
||||
await attachments.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
command.ActorId,
|
||||
Source: "web",
|
||||
Action: "attachment.uploaded",
|
||||
TargetType: "attachment_reference",
|
||||
TargetId: reference.Id.ToString(),
|
||||
Summary: $"Bijlage '{filename.Value}' geüpload ({quarantined.ByteLength} bytes).",
|
||||
command.CorrelationId,
|
||||
now),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return UploadAttachmentResult.Success(reference.Id, filename.Value, signature.DetectedMediaType, signature.DeliveryPolicy,
|
||||
quarantined.ByteLength, quarantined.Sha256, committed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!committed)
|
||||
{
|
||||
await blobStore.DiscardQuarantineAsync(quarantined.QuarantineToken, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Audit;
|
||||
|
||||
public enum AuditActorKind
|
||||
{
|
||||
System,
|
||||
User,
|
||||
ServiceAccount
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An append-only security/content action record. Never carries passwords, raw tokens,
|
||||
/// session identifiers or page bodies (see docs/12-auth-security.md, "Logging and
|
||||
/// privacy").
|
||||
/// </summary>
|
||||
public sealed record AuditEventRecord(
|
||||
AuditActorKind ActorKind,
|
||||
Guid? ActorId,
|
||||
string Source,
|
||||
string Action,
|
||||
string? TargetType,
|
||||
string? TargetId,
|
||||
string Summary,
|
||||
string? CorrelationId,
|
||||
DateTimeOffset OccurredAt);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Audit;
|
||||
|
||||
public sealed record AuditEventView(
|
||||
long Id, string ActorKind, Guid? ActorId, string Source, string Action,
|
||||
string? TargetType, string? TargetId, string Summary, DateTimeOffset OccurredAt);
|
||||
|
||||
public interface IAuditEventReader
|
||||
{
|
||||
Task<IReadOnlyList<AuditEventView>> ListRecentAsync(int limit, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Audit;
|
||||
|
||||
public interface IAuditEventRecorder
|
||||
{
|
||||
/// <summary>Adds an audit event to the current unit of work without saving it.
|
||||
/// Callers that need one atomic commit with their domain mutation use this rather
|
||||
/// than <see cref="RecordAsync"/>.</summary>
|
||||
void Add(AuditEventRecord auditEvent);
|
||||
|
||||
Task RecordAsync(AuditEventRecord auditEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation;
|
||||
|
||||
public interface IIdempotencyStore
|
||||
{
|
||||
Task<IdempotencyRecord?> FindAsync(Guid callerId, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task SaveAsync(IdempotencyRecord record, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation;
|
||||
|
||||
/// <summary>
|
||||
/// Stages an outbox row on the same unit-of-work (DbContext) as the calling use
|
||||
/// case's own aggregate repository, so the eventual <c>SaveChangesAsync</c> call —
|
||||
/// made by that aggregate repository, not this one — commits both atomically
|
||||
/// (docs/24-mcp-readiness.md "Outbox events": "preserve transactionally correct event
|
||||
/// records"). This port deliberately has no <c>SaveChangesAsync</c> of its own.
|
||||
/// </summary>
|
||||
public interface IOutboxRepository
|
||||
{
|
||||
void Add(OutboxMessage message);
|
||||
|
||||
/// <summary>For the outbox-recovery test and any future dispatcher: every
|
||||
/// not-yet-processed message, oldest first.</summary>
|
||||
Task<IReadOnlyList<OutboxMessage>> GetUnprocessedAsync(int limit, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation;
|
||||
|
||||
public sealed record ServiceAccountWithTokens(ServiceAccount Account, IReadOnlyList<ServiceAccountToken> Tokens);
|
||||
|
||||
public interface IServiceAccountRepository
|
||||
{
|
||||
Task<ServiceAccount?> FindAsync(Guid serviceAccountId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ServiceAccount>> ListAsync(Guid workspaceId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ServiceAccountToken>> GetTokensAsync(Guid serviceAccountId, CancellationToken cancellationToken);
|
||||
|
||||
Task<ServiceAccountToken?> FindTokenAsync(Guid tokenId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Looks up an active token by its public ID, together with its owning
|
||||
/// account — the hot path for every bearer-authenticated request, so this is a
|
||||
/// single indexed lookup, never a table scan.</summary>
|
||||
Task<(ServiceAccount Account, ServiceAccountToken Token)?> FindByPublicIdAsync(string publicId, CancellationToken cancellationToken);
|
||||
|
||||
void Add(ServiceAccount account);
|
||||
|
||||
void AddToken(ServiceAccountToken token);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation;
|
||||
|
||||
public sealed record IssuedToken(string PublicId, string SecretHash, string RawToken);
|
||||
|
||||
/// <summary>
|
||||
/// Generates and verifies <c>ikh_<public-id>_<secret></c> bearer tokens
|
||||
/// (docs/09-api-contract.md "Service account"). The raw secret is never persisted —
|
||||
/// only its hash.
|
||||
/// </summary>
|
||||
public interface IServiceAccountTokenIssuer
|
||||
{
|
||||
IssuedToken IssueNewToken();
|
||||
|
||||
/// <summary>Parses a presented <c>Authorization: Bearer</c> value into its public
|
||||
/// ID and secret, or returns false if it is not well-formed — never throws on
|
||||
/// attacker-controlled input.</summary>
|
||||
bool TryParse(string presentedToken, out string publicId, out string secret);
|
||||
|
||||
string HashSecret(string secret);
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
|
||||
public sealed record AuthenticatedServiceToken(ServiceAccount Account, ServiceAccountToken Token);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a presented <c>Authorization: Bearer</c> value against the database on
|
||||
/// every request — never a cached "still valid" assumption, so revocation and
|
||||
/// expiry take effect on the very next call (docs/09-api-contract.md).
|
||||
/// </summary>
|
||||
public sealed class AuthenticateServiceTokenUseCase(
|
||||
IServiceAccountRepository serviceAccounts,
|
||||
IServiceAccountTokenIssuer tokenIssuer,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<AuthenticatedServiceToken?> ExecuteAsync(string presentedToken, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tokenIssuer.TryParse(presentedToken, out var publicId, out var secret))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var found = await serviceAccounts.FindByPublicIdAsync(publicId, cancellationToken);
|
||||
if (found is not { } pair)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (account, token) = pair;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (!account.Enabled || !token.IsActive(now))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Constant-time-in-effect: the hash comparison itself is a straightforward
|
||||
// equality check, but the secret is compared only after an indexed lookup by
|
||||
// the *public* ID — the caller-supplied secret is never used to search the
|
||||
// table by value, so there is no timing side-channel that reveals whether the
|
||||
// public ID exists independent of the secret being correct.
|
||||
if (tokenIssuer.HashSecret(secret) != token.SecretHash)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
token.MarkUsed(now);
|
||||
await serviceAccounts.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new AuthenticatedServiceToken(account, token);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
|
||||
public sealed record CreateServiceAccountCommand(
|
||||
Guid WorkspaceId, string Name, string Description, IReadOnlyList<string> Scopes, Guid ActorId, string? CorrelationId);
|
||||
|
||||
public sealed record CreateServiceAccountResult(Guid ServiceAccountId, Guid TokenId, string RawToken);
|
||||
|
||||
/// <summary>Creates a service account together with its first token, in one step —
|
||||
/// there is no useful account without at least one credential. The raw token is
|
||||
/// returned exactly once; it cannot be retrieved again after this call
|
||||
/// (docs/09-api-contract.md: "The full token is shown once.").</summary>
|
||||
public sealed class CreateServiceAccountUseCase(
|
||||
IServiceAccountRepository serviceAccounts,
|
||||
IServiceAccountTokenIssuer tokenIssuer,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<CreateServiceAccountResult> ExecuteAsync(CreateServiceAccountCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var account = ServiceAccount.Create(command.WorkspaceId, command.Name, command.Description, command.ActorId, now);
|
||||
|
||||
var issued = tokenIssuer.IssueNewToken();
|
||||
var token = ServiceAccountToken.Create(account.Id, issued.PublicId, issued.SecretHash, command.Scopes, expiresAt: null, now);
|
||||
|
||||
serviceAccounts.Add(account);
|
||||
serviceAccounts.AddToken(token);
|
||||
await serviceAccounts.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "service_account.created",
|
||||
TargetType: "service_account", TargetId: account.Id.ToString(),
|
||||
Summary: $"Service account '{account.Name}' aangemaakt.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
return new CreateServiceAccountResult(account.Id, token.Id, issued.RawToken);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
|
||||
public sealed record IssueServiceAccountTokenCommand(
|
||||
Guid ServiceAccountId, IReadOnlyList<string> Scopes, Guid ActorId, string? CorrelationId);
|
||||
|
||||
public sealed record IssueServiceAccountTokenResult(Guid? TokenId, string? RawToken);
|
||||
|
||||
/// <summary>Adds a new token to an existing account — the rotation pattern is
|
||||
/// "issue a new token, update the client, then revoke the old one" rather than an
|
||||
/// in-place secret replacement, so a credential is never briefly unusable
|
||||
/// (docs/09-api-contract.md "rotate").</summary>
|
||||
public sealed class IssueServiceAccountTokenUseCase(
|
||||
IServiceAccountRepository serviceAccounts,
|
||||
IServiceAccountTokenIssuer tokenIssuer,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<IssueServiceAccountTokenResult> ExecuteAsync(IssueServiceAccountTokenCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await serviceAccounts.FindAsync(command.ServiceAccountId, cancellationToken);
|
||||
if (account is null)
|
||||
{
|
||||
return new IssueServiceAccountTokenResult(null, null);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var issued = tokenIssuer.IssueNewToken();
|
||||
var token = ServiceAccountToken.Create(account.Id, issued.PublicId, issued.SecretHash, command.Scopes, expiresAt: null, now);
|
||||
|
||||
serviceAccounts.AddToken(token);
|
||||
await serviceAccounts.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "service_account.token_issued",
|
||||
TargetType: "service_account", TargetId: account.Id.ToString(),
|
||||
Summary: $"Nieuw token uitgegeven voor service account '{account.Name}'.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
return new IssueServiceAccountTokenResult(token.Id, issued.RawToken);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
|
||||
public sealed record ServiceAccountView(ServiceAccount Account, IReadOnlyList<ServiceAccountToken> Tokens);
|
||||
|
||||
public sealed class ListServiceAccountsUseCase(IServiceAccountRepository serviceAccounts)
|
||||
{
|
||||
public async Task<IReadOnlyList<ServiceAccountView>> ExecuteAsync(Guid workspaceId, CancellationToken cancellationToken)
|
||||
{
|
||||
var accounts = await serviceAccounts.ListAsync(workspaceId, cancellationToken);
|
||||
var views = new List<ServiceAccountView>(accounts.Count);
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var tokens = await serviceAccounts.GetTokensAsync(account.Id, cancellationToken);
|
||||
views.Add(new ServiceAccountView(account, tokens));
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Automation.UseCases;
|
||||
|
||||
public sealed record RevokeServiceAccountTokenCommand(Guid TokenId, Guid ActorId, string? CorrelationId);
|
||||
|
||||
/// <summary>Revocation takes effect immediately for the very next request — tokens are
|
||||
/// checked live against the database on every call, never cached as "valid" for the
|
||||
/// lifetime of a process (docs/09-api-contract.md "can be revoked").</summary>
|
||||
public sealed class RevokeServiceAccountTokenUseCase(
|
||||
IServiceAccountRepository serviceAccounts,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<bool> ExecuteAsync(RevokeServiceAccountTokenCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await serviceAccounts.FindTokenAsync(command.TokenId, cancellationToken);
|
||||
if (token is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
token.Revoke(now);
|
||||
await serviceAccounts.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "service_account.token_revoked",
|
||||
TargetType: "service_account_token", TargetId: token.Id.ToString(),
|
||||
Summary: "Service-accounttoken ingetrokken.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
/// <summary>Shared outcome shape for Projects/Decisions/Runbooks use cases — mirrors
|
||||
/// <c>Knowledge.UseCases.PageOperationResult</c>'s established pattern without
|
||||
/// duplicating it per module.</summary>
|
||||
public enum OperationStatus
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
ConcurrencyConflict,
|
||||
ValidationFailed,
|
||||
InvalidTransition
|
||||
}
|
||||
|
||||
public sealed record OperationResult(OperationStatus Status, string? ETag, IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static OperationResult Success(string? etag = null) => new(OperationStatus.Success, etag, []);
|
||||
|
||||
public static OperationResult Failure(OperationStatus status, IReadOnlyList<string>? errors = null) =>
|
||||
new(status, null, errors ?? []);
|
||||
}
|
||||
|
||||
/// <summary>A result that also carries a created/loaded value (e.g. a new entity's ID)
|
||||
/// on success. Factory methods live on the non-generic <see cref="OperationResultValue"/>
|
||||
/// helper, not on this generic type itself, since CA1000 forbids static members on
|
||||
/// generic types; call-site type inference (<c>OperationResultValue.Success(value)</c>)
|
||||
/// reads the same as a static member would.</summary>
|
||||
public sealed record OperationResultValue<T>(OperationStatus Status, T? Value, string? ETag, IReadOnlyList<string> Errors);
|
||||
|
||||
public static class OperationResultValue
|
||||
{
|
||||
public static OperationResultValue<T> Success<T>(T value, string? etag = null) =>
|
||||
new(OperationStatus.Success, value, etag, []);
|
||||
|
||||
public static OperationResultValue<T> Failure<T>(OperationStatus status, IReadOnlyList<string>? errors = null) =>
|
||||
new(status, default, null, errors ?? []);
|
||||
}
|
||||
|
||||
/// <summary>Thrown by a repository's <c>SaveChangesAsync</c> when an entity loaded for
|
||||
/// update via an expected ETag changed underneath the current unit of work — the same
|
||||
/// pattern as <c>Knowledge.IPageRepository.ConcurrencyConflictException</c>, shared here
|
||||
/// across the Projects/Decisions/Runbooks repositories.</summary>
|
||||
public sealed class ConcurrencyConflictException(Guid entityId) : Exception($"Entity {entityId} was modified by another request.")
|
||||
{
|
||||
public Guid EntityId { get; } = entityId;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Shared "is this URL safe to store and later render as a link" check
|
||||
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2). Only
|
||||
/// `http`/`https` are accepted by default — no `javascript:`, `data:`, `file:` or any
|
||||
/// other scheme, regardless of how a client encodes it, and the value is never
|
||||
/// silently rewritten (no normalization that could change its meaning): a caller
|
||||
/// either gets back exactly the trimmed input it sent, or an explicit validation
|
||||
/// failure it can react to.
|
||||
/// </summary>
|
||||
public static class SafeUrlValidator
|
||||
{
|
||||
private static readonly string[] DefaultAllowedSchemes = ["http", "https"];
|
||||
|
||||
/// <summary>Returns <c>null</c> when <paramref name="value"/> is empty (an
|
||||
/// optional URL field left blank is always valid) or a well-formed absolute URL
|
||||
/// using an allowed scheme; otherwise a single human-readable Dutch validation
|
||||
/// message naming <paramref name="fieldLabel"/>.</summary>
|
||||
public static string? Validate(string? value, string fieldLabel, int maxLength, IReadOnlyCollection<string>? allowedSchemes = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length > maxLength)
|
||||
{
|
||||
return $"{fieldLabel} mag maximaal {maxLength} tekens bevatten.";
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return $"{fieldLabel} moet een geldige, absolute URL zijn.";
|
||||
}
|
||||
|
||||
var schemes = allowedSchemes ?? DefaultAllowedSchemes;
|
||||
if (!schemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"{fieldLabel} moet beginnen met {string.Join(" of ", schemes)}.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Decisions;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions;
|
||||
|
||||
public sealed record DecisionListPage(IReadOnlyList<DecisionRecord> Items, string? NextCursor);
|
||||
|
||||
/// <summary>Application-facing persistence port for Architecture Decision Records.
|
||||
/// Concurrency uses the same <c>xmin</c>-as-opaque-ETag pattern as
|
||||
/// <c>Knowledge.IPageRepository</c>.</summary>
|
||||
public interface IDecisionRepository
|
||||
{
|
||||
Task<DecisionRecord?> FindAsync(Guid decisionId, CancellationToken cancellationToken);
|
||||
|
||||
Task<DecisionRecord?> FindByPageIdAsync(Guid pageId, CancellationToken cancellationToken);
|
||||
|
||||
Task<string?> GetETagAsync(Guid decisionId, CancellationToken cancellationToken);
|
||||
|
||||
Task<DecisionRecord?> FindForUpdateAsync(Guid decisionId, string expectedETag, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The next decision number to assign within the given scope (workspace,
|
||||
/// and project when not null) — computed as one more than the current maximum,
|
||||
/// inside the same transaction as the record's creation.</summary>
|
||||
Task<int> GetNextDecisionNumberAsync(Guid workspaceId, Guid? projectId, CancellationToken cancellationToken);
|
||||
|
||||
Task<DecisionListPage> ListAsync(
|
||||
Guid workspaceId, Guid? projectId, string? status, string? cursor, int limit, CancellationToken cancellationToken);
|
||||
|
||||
void Add(DecisionRecord decision);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record AcceptDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId);
|
||||
|
||||
/// <summary>Accepting a decision that itself names an earlier one to supersede
|
||||
/// (docs/16-runbooks-and-adrs.md — "An accepted ADR can be superseded only by another
|
||||
/// accepted ADR") atomically flips that earlier, currently-accepted decision to
|
||||
/// Superseded and links both records bidirectionally.</summary>
|
||||
public sealed class AcceptDecisionUseCase(
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResult> ExecuteAsync(AcceptDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
decision.Accept(now);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]);
|
||||
}
|
||||
|
||||
if (decision.SupersedesDecisionId is { } supersedesId)
|
||||
{
|
||||
var target = await decisions.FindAsync(supersedesId, cancellationToken);
|
||||
if (target is null || target.Status != Domain.Decisions.DecisionStatus.Accepted)
|
||||
{
|
||||
return OperationResult.Failure(
|
||||
OperationStatus.ValidationFailed,
|
||||
["De te vervangen beslissing moet momenteel geaccepteerd zijn."]);
|
||||
}
|
||||
|
||||
target.MarkSupersededBy(decision.Id, now);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Common.ConcurrencyConflictException)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.accepted",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{decision.DecisionNumber} geaccepteerd.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
var etag = await decisions.GetETagAsync(decision.Id, cancellationToken);
|
||||
return OperationResult.Success(etag);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Automation;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Decisions;
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record CreateDecisionCommand(
|
||||
Guid WorkspaceId,
|
||||
Guid? ParentPageId,
|
||||
Guid? ProjectId,
|
||||
string Title,
|
||||
string? Markdown,
|
||||
string Summary,
|
||||
Guid? SupersedesDecisionId,
|
||||
Guid ActorId,
|
||||
string? CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ADR as one transactional-in-intent step: a <c>Page</c> (documentType
|
||||
/// <c>ArchitectureDecision</c>, so it gets the full Markdown editor, revisions and
|
||||
/// search like any other page) plus its <see cref="DecisionRecord"/> lifecycle profile
|
||||
/// (docs/16-runbooks-and-adrs.md — "unique page profile"). The two steps commit
|
||||
/// sequentially rather than in one database transaction (ADR-014); if profile creation
|
||||
/// fails after the page succeeded, the page is best-effort trashed so no orphaned,
|
||||
/// profile-less ArchitectureDecision page is left visible.
|
||||
/// </summary>
|
||||
public sealed class CreateDecisionUseCase(
|
||||
CreatePageUseCase createPage,
|
||||
IPageRepository pages,
|
||||
TrashPageUseCase trashPage,
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
IOutboxRepository outbox,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResultValue<Guid>> ExecuteAsync(CreateDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Title))
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, ["Titel is verplicht."]);
|
||||
}
|
||||
|
||||
DecisionRecord? supersedes = null;
|
||||
if (command.SupersedesDecisionId is { } supersedesId)
|
||||
{
|
||||
supersedes = await decisions.FindAsync(supersedesId, cancellationToken);
|
||||
if (supersedes is null)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, ["De te vervangen beslissing bestaat niet."]);
|
||||
}
|
||||
}
|
||||
|
||||
var pageResult = await createPage.ExecuteAsync(
|
||||
new CreatePageCommand(
|
||||
command.WorkspaceId, command.ParentPageId, command.Title, DocumentType.ArchitectureDecision.ToString(),
|
||||
command.Markdown, TemplateKey: null, Tags: null, command.ActorId, command.CorrelationId),
|
||||
cancellationToken);
|
||||
|
||||
if (pageResult.Status != PageOperationStatus.Success)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, pageResult.Errors);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var number = await decisions.GetNextDecisionNumberAsync(command.WorkspaceId, command.ProjectId, cancellationToken);
|
||||
var decision = DecisionRecord.Create(
|
||||
command.WorkspaceId, command.ProjectId, pageResult.PageId!.Value, number,
|
||||
DateOnly.FromDateTime(now.UtcDateTime), command.Summary, command.SupersedesDecisionId,
|
||||
command.ActorId, now);
|
||||
|
||||
decisions.Add(decision);
|
||||
|
||||
outbox.Add(OutboxMessage.Create(
|
||||
"decision.created.v1", "decision", decision.Id.ToString(),
|
||||
JsonSerializer.Serialize(new { decisionId = decision.Id, pageId = decision.PageId, decisionNumber = number }),
|
||||
now));
|
||||
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.created",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{number} '{command.Title}' aangemaakt.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
return OperationResultValue.Success(decision.Id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
var page = await pages.FindAsync(pageResult.PageId!.Value, cancellationToken);
|
||||
var etag = await pages.GetETagAsync(pageResult.PageId!.Value, cancellationToken);
|
||||
if (page is not null && etag is not null)
|
||||
{
|
||||
await trashPage.ExecuteAsync(
|
||||
new TrashPageCommand(pageResult.PageId!.Value, etag, command.ActorId, command.CorrelationId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Domain.Decisions;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record DecisionDetailResult(
|
||||
DecisionRecord Decision,
|
||||
PageDetailResult Page,
|
||||
string ETag,
|
||||
DecisionRecord? Supersedes,
|
||||
DecisionRecord? SupersededBy);
|
||||
|
||||
/// <summary>Assembles one ADR's full lineage view: its own lifecycle metadata, the
|
||||
/// linked page's rendered content, and — resolved by one extra lookup each, never a
|
||||
/// duplicated copy — the decision it supersedes and/or is superseded by
|
||||
/// (docs/16-runbooks-and-adrs.md: "Links are bidirectional").</summary>
|
||||
public sealed class GetDecisionUseCase(IDecisionRepository decisions, GetPageUseCase getPage)
|
||||
{
|
||||
public async Task<DecisionDetailResult?> ExecuteAsync(Guid decisionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindAsync(decisionId, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var page = await getPage.ExecuteAsync(decision.PageId, cancellationToken);
|
||||
if (page is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var etag = await decisions.GetETagAsync(decisionId, cancellationToken) ?? string.Empty;
|
||||
var supersedes = decision.SupersedesDecisionId is { } supersedesId
|
||||
? await decisions.FindAsync(supersedesId, cancellationToken)
|
||||
: null;
|
||||
var supersededBy = decision.SupersededByDecisionId is { } supersededById
|
||||
? await decisions.FindAsync(supersededById, cancellationToken)
|
||||
: null;
|
||||
|
||||
return new DecisionDetailResult(decision, page, etag, supersedes, supersededBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed class ListDecisionsUseCase(IDecisionRepository decisions)
|
||||
{
|
||||
public Task<DecisionListPage> ExecuteAsync(
|
||||
Guid workspaceId, Guid? projectId, string? status, string? cursor, int limit, CancellationToken cancellationToken) =>
|
||||
decisions.ListAsync(workspaceId, projectId, status, cursor, limit, cancellationToken);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record RejectDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId);
|
||||
|
||||
public sealed class RejectDecisionUseCase(
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResult> ExecuteAsync(RejectDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
decision.Reject(now);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Common.ConcurrencyConflictException)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.rejected",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{decision.DecisionNumber} afgewezen.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
var etag = await decisions.GetETagAsync(decision.Id, cancellationToken);
|
||||
return OperationResult.Success(etag);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Decisions.UseCases;
|
||||
|
||||
public sealed record WithdrawDecisionCommand(Guid DecisionId, string ExpectedETag, Guid ActorId, string? CorrelationId);
|
||||
|
||||
public sealed class WithdrawDecisionUseCase(
|
||||
IDecisionRepository decisions,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResult> ExecuteAsync(WithdrawDecisionCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var decision = await decisions.FindForUpdateAsync(command.DecisionId, command.ExpectedETag, cancellationToken);
|
||||
if (decision is null)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
decision.Withdraw(now);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.InvalidTransition, [ex.Message]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await decisions.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Common.ConcurrencyConflictException)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, Source: "web", Action: "decision.withdrawn",
|
||||
TargetType: "decision", TargetId: decision.Id.ToString(),
|
||||
Summary: $"ADR-{decision.DecisionNumber} ingetrokken.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
var etag = await decisions.GetETagAsync(decision.Id, cancellationToken);
|
||||
return OperationResult.Success(etag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
public interface IInstallationRepository
|
||||
{
|
||||
Task<bool> IsSetupCompleteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Marks setup complete. Throws <see cref="InvalidOperationException"/> if it was
|
||||
/// already complete — callers must treat that as "setup already ran", never as a
|
||||
/// silent success, to guarantee the first-run flow is genuinely single-use even
|
||||
/// under a concurrent double submit.
|
||||
/// </summary>
|
||||
Task CompleteSetupAsync(DateTimeOffset completedAt, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
public sealed record IssuedRecoveryToken(string RawToken, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Operator-issued single-use password-recovery tokens (see docs/12-auth-security.md,
|
||||
/// "Operator-issued recovery"). Only a non-reversible verifier is ever persisted; the raw
|
||||
/// token is returned once, at issuance, for the administrator to deliver out of band.
|
||||
/// </summary>
|
||||
public interface IRecoveryTokenService
|
||||
{
|
||||
Task<IssuedRecoveryToken> IssueAsync(Guid userId, Guid issuedByUserId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Validates and consumes a token atomically. Returns the associated user
|
||||
/// ID, or <see langword="null"/> when the token is missing, expired or already
|
||||
/// used.</summary>
|
||||
Task<Guid?> RedeemAsync(string rawToken, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Application-facing port over the interactive-account store and sign-in mechanism.
|
||||
/// Infrastructure implements this using ASP.NET Core Identity so password hashing,
|
||||
/// lockout, security-stamp invalidation and cookie sign-in stay on a reviewed
|
||||
/// implementation rather than custom crypto (see docs/12-auth-security.md).
|
||||
/// </summary>
|
||||
public interface IUserAccountManager
|
||||
{
|
||||
Task<UserAccountCreationResult> CreateAsync(
|
||||
DisplayName displayName,
|
||||
EmailAddress email,
|
||||
string password,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies credentials and, on success, signs the caller in with a browser cookie
|
||||
/// session. Applies lockout after repeated failures and rejects disabled accounts
|
||||
/// without confirming whether the account exists.
|
||||
/// </summary>
|
||||
Task<PasswordSignInOutcome> PasswordSignInAsync(
|
||||
EmailAddress email,
|
||||
string password,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task SignOutAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Resolves the account for the current authenticated HTTP request, or
|
||||
/// <see langword="null"/> when there is none.</summary>
|
||||
Task<UserAccount?> GetSignedInAccountAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<UserAccount?> FindByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<UserAccount?> FindByEmailAsync(EmailAddress email, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<UserAccount>> ListAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Enables/disables an account. Disabling immediately invalidates existing
|
||||
/// sessions by rotating the security stamp.</summary>
|
||||
Task SetEnabledAsync(Guid userId, bool enabled, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Sets a new password directly, bypassing Identity's own reset-token flow,
|
||||
/// because the caller (recovery redemption) has already validated a separately
|
||||
/// stored, single-use recovery token. Rotates the security stamp so any other
|
||||
/// existing session is invalidated.</summary>
|
||||
Task<IReadOnlyList<string>> SetPasswordDirectAsync(
|
||||
Guid userId,
|
||||
string newPassword,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
public sealed record MembershipSummary(Guid UserId, WorkspaceRole Role);
|
||||
|
||||
public interface IWorkspaceMembershipRepository
|
||||
{
|
||||
Task AddAsync(
|
||||
Guid workspaceId,
|
||||
Guid userId,
|
||||
WorkspaceRole role,
|
||||
DateTimeOffset createdAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<WorkspaceRole?> GetRoleAsync(Guid workspaceId, Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<MembershipSummary>> ListAsync(Guid workspaceId, CancellationToken cancellationToken);
|
||||
|
||||
Task ChangeRoleAsync(
|
||||
Guid workspaceId,
|
||||
Guid userId,
|
||||
WorkspaceRole role,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
public interface IWorkspaceRepository
|
||||
{
|
||||
Task<Guid?> GetDefaultWorkspaceIdAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<Guid> CreateAsync(WorkspaceName name, DateTimeOffset createdAt, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
public enum PasswordSignInStatus
|
||||
{
|
||||
Success,
|
||||
InvalidCredentials,
|
||||
LockedOut,
|
||||
AccountDisabled
|
||||
}
|
||||
|
||||
public sealed record PasswordSignInOutcome(PasswordSignInStatus Status, UserAccount? Account)
|
||||
{
|
||||
public static PasswordSignInOutcome Success(UserAccount account) =>
|
||||
new(PasswordSignInStatus.Success, account);
|
||||
|
||||
public static readonly PasswordSignInOutcome InvalidCredentials =
|
||||
new(PasswordSignInStatus.InvalidCredentials, null);
|
||||
|
||||
public static readonly PasswordSignInOutcome LockedOut =
|
||||
new(PasswordSignInStatus.LockedOut, null);
|
||||
|
||||
public static readonly PasswordSignInOutcome AccountDisabled =
|
||||
new(PasswordSignInStatus.AccountDisabled, null);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public enum CompleteFirstRunSetupStatus
|
||||
{
|
||||
Success,
|
||||
AlreadyCompleted,
|
||||
EmailAlreadyInUse,
|
||||
PasswordRejected
|
||||
}
|
||||
|
||||
public sealed record CompleteFirstRunSetupResult(
|
||||
CompleteFirstRunSetupStatus Status,
|
||||
Guid? WorkspaceId,
|
||||
Guid? UserId,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static CompleteFirstRunSetupResult Success(Guid workspaceId, Guid userId) =>
|
||||
new(CompleteFirstRunSetupStatus.Success, workspaceId, userId, []);
|
||||
|
||||
public static CompleteFirstRunSetupResult Failure(
|
||||
CompleteFirstRunSetupStatus status,
|
||||
IReadOnlyList<string>? errors = null) =>
|
||||
new(status, null, null, errors ?? []);
|
||||
}
|
||||
|
||||
public sealed record CompleteFirstRunSetupCommand(
|
||||
string WorkspaceName,
|
||||
string DisplayName,
|
||||
string Email,
|
||||
string Password,
|
||||
string? CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically creates the installation's default workspace, owner account and
|
||||
/// administrator membership. Setup remains single-use and is protected by CSRF and
|
||||
/// rate limiting. See docs/02-personas-and-user-journeys.md, journey J1.
|
||||
/// </summary>
|
||||
public sealed class CompleteFirstRunSetupUseCase(
|
||||
IInstallationRepository installations,
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships,
|
||||
IUserAccountManager userAccounts,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<CompleteFirstRunSetupResult> ExecuteAsync(
|
||||
CompleteFirstRunSetupCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await installations.IsSetupCompleteAsync(cancellationToken))
|
||||
{
|
||||
return CompleteFirstRunSetupResult.Failure(CompleteFirstRunSetupStatus.AlreadyCompleted);
|
||||
}
|
||||
|
||||
|
||||
if (!EmailAddress.TryCreate(command.Email, out var email))
|
||||
{
|
||||
return CompleteFirstRunSetupResult.Failure(
|
||||
CompleteFirstRunSetupStatus.PasswordRejected,
|
||||
["Het e-mailadres heeft geen geldige vorm."]);
|
||||
}
|
||||
|
||||
var displayName = DisplayName.Create(command.DisplayName);
|
||||
var workspaceName = WorkspaceName.Create(command.WorkspaceName);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var creation = await userAccounts.CreateAsync(displayName, email!, command.Password, cancellationToken);
|
||||
if (creation.Status == UserAccountCreationStatus.EmailAlreadyInUse)
|
||||
{
|
||||
return CompleteFirstRunSetupResult.Failure(CompleteFirstRunSetupStatus.EmailAlreadyInUse);
|
||||
}
|
||||
|
||||
if (creation.Status == UserAccountCreationStatus.PasswordRejected)
|
||||
{
|
||||
return CompleteFirstRunSetupResult.Failure(
|
||||
CompleteFirstRunSetupStatus.PasswordRejected,
|
||||
creation.Errors);
|
||||
}
|
||||
|
||||
var workspaceId = await workspaces.CreateAsync(workspaceName, now, cancellationToken);
|
||||
await memberships.AddAsync(workspaceId, creation.Account!.Id, WorkspaceRole.Administrator, now, cancellationToken);
|
||||
|
||||
// Setup completion happens only after the workspace, owner and membership
|
||||
// exist, so a failure earlier in this method leaves setup retryable rather than
|
||||
// half-completed with no way back in.
|
||||
await installations.CompleteSetupAsync(now, cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
creation.Account.Id,
|
||||
Source: "web",
|
||||
Action: "installation.setup_completed",
|
||||
TargetType: "workspace",
|
||||
TargetId: workspaceId.ToString(),
|
||||
Summary: "Eerste installatie voltooid; eigenaar aangemaakt.",
|
||||
command.CorrelationId,
|
||||
now),
|
||||
cancellationToken);
|
||||
|
||||
return CompleteFirstRunSetupResult.Success(workspaceId, creation.Account.Id);
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public enum CreateWorkspaceUserStatus
|
||||
{
|
||||
Success,
|
||||
EmailAlreadyInUse,
|
||||
PasswordRejected,
|
||||
ValidationFailed,
|
||||
WorkspaceNotFound
|
||||
}
|
||||
|
||||
public sealed record CreateWorkspaceUserResult(
|
||||
CreateWorkspaceUserStatus Status,
|
||||
UserAccount? Account,
|
||||
WorkspaceRole? Role,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static CreateWorkspaceUserResult Success(UserAccount account, WorkspaceRole role) =>
|
||||
new(CreateWorkspaceUserStatus.Success, account, role, []);
|
||||
|
||||
public static CreateWorkspaceUserResult Failure(
|
||||
CreateWorkspaceUserStatus status,
|
||||
IReadOnlyList<string>? errors = null) =>
|
||||
new(status, null, null, errors ?? []);
|
||||
}
|
||||
|
||||
/// <summary>Administrator-only onboarding of another local account. There is no invite
|
||||
/// email; the administrator sets an initial password and communicates it out of band,
|
||||
/// consistent with the "no email provider required" design (docs/12-auth-security.md).
|
||||
/// </summary>
|
||||
public sealed class CreateWorkspaceUserUseCase(
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships,
|
||||
IUserAccountManager userAccounts,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<CreateWorkspaceUserResult> ExecuteAsync(
|
||||
string displayName,
|
||||
string email,
|
||||
string password,
|
||||
string role,
|
||||
Guid createdByUserId,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.TryParse<WorkspaceRole>(role, ignoreCase: true, out var parsedRole))
|
||||
{
|
||||
return CreateWorkspaceUserResult.Failure(
|
||||
CreateWorkspaceUserStatus.ValidationFailed,
|
||||
["Onbekende rol."]);
|
||||
}
|
||||
|
||||
if (!EmailAddress.TryCreate(email, out var emailAddress))
|
||||
{
|
||||
return CreateWorkspaceUserResult.Failure(
|
||||
CreateWorkspaceUserStatus.ValidationFailed,
|
||||
["Het e-mailadres heeft geen geldige vorm."]);
|
||||
}
|
||||
|
||||
DisplayName parsedDisplayName;
|
||||
try
|
||||
{
|
||||
parsedDisplayName = DisplayName.Create(displayName);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.ValidationFailed, [ex.Message]);
|
||||
}
|
||||
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.WorkspaceNotFound);
|
||||
}
|
||||
|
||||
var creation = await userAccounts.CreateAsync(parsedDisplayName, emailAddress!, password, cancellationToken);
|
||||
if (creation.Status == UserAccountCreationStatus.EmailAlreadyInUse)
|
||||
{
|
||||
return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.EmailAlreadyInUse);
|
||||
}
|
||||
|
||||
if (creation.Status == UserAccountCreationStatus.PasswordRejected)
|
||||
{
|
||||
return CreateWorkspaceUserResult.Failure(CreateWorkspaceUserStatus.PasswordRejected, creation.Errors);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
await memberships.AddAsync(workspaceId.Value, creation.Account!.Id, parsedRole, now, cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
createdByUserId,
|
||||
Source: "web",
|
||||
Action: "user.created",
|
||||
TargetType: "user",
|
||||
TargetId: creation.Account.Id.ToString(),
|
||||
Summary: $"Gebruiker aangemaakt met rol {parsedRole}.",
|
||||
correlationId,
|
||||
now),
|
||||
cancellationToken);
|
||||
|
||||
return CreateWorkspaceUserResult.Success(creation.Account, parsedRole);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public sealed record SessionInfo(
|
||||
bool Authenticated,
|
||||
bool SetupRequired,
|
||||
Guid? UserId,
|
||||
string? DisplayName,
|
||||
string? Email,
|
||||
Guid? WorkspaceId,
|
||||
WorkspaceRole? Role);
|
||||
|
||||
public sealed class GetSessionUseCase(
|
||||
IInstallationRepository installations,
|
||||
IUserAccountManager userAccounts,
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships)
|
||||
{
|
||||
public async Task<SessionInfo> ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var setupComplete = await installations.IsSetupCompleteAsync(cancellationToken);
|
||||
var account = await userAccounts.GetSignedInAccountAsync(cancellationToken);
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
return new SessionInfo(false, !setupComplete, null, null, null, null, null);
|
||||
}
|
||||
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
WorkspaceRole? role = workspaceId is null
|
||||
? null
|
||||
: await memberships.GetRoleAsync(workspaceId.Value, account.Id, cancellationToken);
|
||||
|
||||
return new SessionInfo(
|
||||
true,
|
||||
!setupComplete,
|
||||
account.Id,
|
||||
account.DisplayName,
|
||||
account.Email,
|
||||
workspaceId,
|
||||
role);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public enum IssueRecoveryTokenStatus
|
||||
{
|
||||
Success,
|
||||
UserNotFound
|
||||
}
|
||||
|
||||
public sealed record IssueRecoveryTokenResult(
|
||||
IssueRecoveryTokenStatus Status,
|
||||
string? RawToken,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>Administrator-only: issues a single-use recovery token for another local
|
||||
/// account, satisfying the "no email provider required" operator-issued recovery
|
||||
/// journey (docs/12-auth-security.md).</summary>
|
||||
public sealed class IssueRecoveryTokenUseCase(
|
||||
IUserAccountManager userAccounts,
|
||||
IRecoveryTokenService recoveryTokens,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<IssueRecoveryTokenResult> ExecuteAsync(
|
||||
Guid targetUserId,
|
||||
Guid issuedByUserId,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await userAccounts.FindByIdAsync(targetUserId, cancellationToken);
|
||||
if (account is null)
|
||||
{
|
||||
return new IssueRecoveryTokenResult(IssueRecoveryTokenStatus.UserNotFound, null, null);
|
||||
}
|
||||
|
||||
var issued = await recoveryTokens.IssueAsync(targetUserId, issuedByUserId, cancellationToken);
|
||||
|
||||
// Deliberately never include the raw token in the audit summary.
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
issuedByUserId,
|
||||
Source: "web",
|
||||
Action: "auth.recovery_token_issued",
|
||||
TargetType: "user",
|
||||
TargetId: targetUserId.ToString(),
|
||||
Summary: "Herstelcode uitgegeven door beheerder.",
|
||||
correlationId,
|
||||
timeProvider.GetUtcNow()),
|
||||
cancellationToken);
|
||||
|
||||
return new IssueRecoveryTokenResult(
|
||||
IssueRecoveryTokenStatus.Success,
|
||||
issued.RawToken,
|
||||
issued.ExpiresAt);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public sealed record WorkspaceUserSummary(UserAccount Account, WorkspaceRole Role);
|
||||
|
||||
public sealed class ListWorkspaceUsersUseCase(
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships,
|
||||
IUserAccountManager userAccounts)
|
||||
{
|
||||
public async Task<IReadOnlyList<WorkspaceUserSummary>> ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var membershipList = await memberships.ListAsync(workspaceId.Value, cancellationToken);
|
||||
var accounts = await userAccounts.ListAsync(cancellationToken);
|
||||
var accountsById = accounts.ToDictionary(account => account.Id);
|
||||
|
||||
var result = new List<WorkspaceUserSummary>(membershipList.Count);
|
||||
foreach (var membership in membershipList)
|
||||
{
|
||||
if (accountsById.TryGetValue(membership.UserId, out var account))
|
||||
{
|
||||
result.Add(new WorkspaceUserSummary(account, membership.Role));
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
.OrderBy(summary => summary.Account.DisplayName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public sealed record LoginCommand(string Email, string Password, string? CorrelationId);
|
||||
|
||||
public sealed class LoginUseCase(
|
||||
IUserAccountManager userAccounts,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<PasswordSignInOutcome> ExecuteAsync(LoginCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// An invalid email shape is treated identically to "invalid credentials" so the
|
||||
// response never confirms whether an account with that address exists.
|
||||
if (!EmailAddress.TryCreate(command.Email, out var email))
|
||||
{
|
||||
await RecordAsync(null, "auth.login_failed", "Ongeldige aanmeldpoging.", command.CorrelationId, now, cancellationToken);
|
||||
return PasswordSignInOutcome.InvalidCredentials;
|
||||
}
|
||||
|
||||
var outcome = await userAccounts.PasswordSignInAsync(email!, command.Password, cancellationToken);
|
||||
|
||||
var (action, summary) = outcome.Status switch
|
||||
{
|
||||
PasswordSignInStatus.Success => ("auth.login_succeeded", "Aanmelding geslaagd."),
|
||||
PasswordSignInStatus.LockedOut => ("auth.login_locked_out", "Account tijdelijk vergrendeld na herhaalde mislukte pogingen."),
|
||||
PasswordSignInStatus.AccountDisabled => ("auth.login_failed", "Aanmeldpoging voor uitgeschakeld account."),
|
||||
_ => ("auth.login_failed", "Ongeldige aanmeldpoging.")
|
||||
};
|
||||
|
||||
await RecordAsync(outcome.Account?.Id, action, summary, command.CorrelationId, now, cancellationToken);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private Task RecordAsync(
|
||||
Guid? userId,
|
||||
string action,
|
||||
string summary,
|
||||
string? correlationId,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) =>
|
||||
auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
userId is null ? AuditActorKind.System : AuditActorKind.User,
|
||||
userId,
|
||||
Source: "web",
|
||||
Action: action,
|
||||
TargetType: userId is null ? null : "user",
|
||||
TargetId: userId?.ToString(),
|
||||
Summary: summary,
|
||||
correlationId,
|
||||
now),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public sealed class LogoutUseCase(
|
||||
IUserAccountManager userAccounts,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task ExecuteAsync(string? correlationId, CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await userAccounts.GetSignedInAccountAsync(cancellationToken);
|
||||
await userAccounts.SignOutAsync(cancellationToken);
|
||||
|
||||
if (account is not null)
|
||||
{
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
account.Id,
|
||||
Source: "web",
|
||||
Action: "auth.logout",
|
||||
TargetType: "user",
|
||||
TargetId: account.Id.ToString(),
|
||||
Summary: "Afgemeld.",
|
||||
correlationId,
|
||||
timeProvider.GetUtcNow()),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public enum RedeemRecoveryTokenStatus
|
||||
{
|
||||
Success,
|
||||
InvalidOrExpiredToken,
|
||||
PasswordRejected
|
||||
}
|
||||
|
||||
public sealed record RedeemRecoveryTokenResult(
|
||||
RedeemRecoveryTokenStatus Status,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static readonly RedeemRecoveryTokenResult Success =
|
||||
new(RedeemRecoveryTokenStatus.Success, []);
|
||||
|
||||
public static readonly RedeemRecoveryTokenResult InvalidOrExpiredToken =
|
||||
new(RedeemRecoveryTokenStatus.InvalidOrExpiredToken, []);
|
||||
|
||||
public static RedeemRecoveryTokenResult PasswordRejected(IReadOnlyList<string> errors) =>
|
||||
new(RedeemRecoveryTokenStatus.PasswordRejected, errors);
|
||||
}
|
||||
|
||||
public sealed class RedeemRecoveryTokenUseCase(
|
||||
IRecoveryTokenService recoveryTokens,
|
||||
IUserAccountManager userAccounts,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<RedeemRecoveryTokenResult> ExecuteAsync(
|
||||
string rawToken,
|
||||
string newPassword,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The token is consumed atomically on lookup, before the new password is
|
||||
// validated. This is a deliberate trade-off: it prevents a single leaked/guessed
|
||||
// token from being replayed against multiple candidate passwords, at the cost of
|
||||
// requiring the operator to issue a fresh token if the first attempted password
|
||||
// is rejected.
|
||||
var userId = await recoveryTokens.RedeemAsync(rawToken, cancellationToken);
|
||||
if (userId is null)
|
||||
{
|
||||
return RedeemRecoveryTokenResult.InvalidOrExpiredToken;
|
||||
}
|
||||
|
||||
var errors = await userAccounts.SetPasswordDirectAsync(userId.Value, newPassword, cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return RedeemRecoveryTokenResult.PasswordRejected(errors);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
userId,
|
||||
Source: "web",
|
||||
Action: "auth.recovery_token_redeemed",
|
||||
TargetType: "user",
|
||||
TargetId: userId.Value.ToString(),
|
||||
Summary: "Wachtwoord hersteld via eenmalige herstelcode.",
|
||||
correlationId,
|
||||
timeProvider.GetUtcNow()),
|
||||
cancellationToken);
|
||||
|
||||
return RedeemRecoveryTokenResult.Success;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public sealed record WorkspaceMemberSearchResult(Guid Id, string DisplayName, string Email);
|
||||
|
||||
/// <summary>
|
||||
/// Backs the accessible owner picker (docs/adr/ADR-029-v2-r5-final-hardening.md,
|
||||
/// brief Phase 7) — replaces manual `OwnerId`/GUID entry in editorial-profile and
|
||||
/// project forms. Deliberately narrower than <c>ListWorkspaceUsersUseCase</c> (the
|
||||
/// Administrator-only `GET /admin/users`): only enabled members of the current
|
||||
/// workspace, only the three fields a picker needs, and reachable by any Reader —
|
||||
/// never a way to enumerate accounts outside the workspace or disabled accounts.
|
||||
/// </summary>
|
||||
public sealed class SearchWorkspaceMembersUseCase(
|
||||
IWorkspaceRepository workspaces, IWorkspaceMembershipRepository memberships, IUserAccountManager userAccounts)
|
||||
{
|
||||
private const int MaxResults = 25;
|
||||
|
||||
public async Task<IReadOnlyList<WorkspaceMemberSearchResult>> ExecuteAsync(
|
||||
string? query, CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var membershipList = await memberships.ListAsync(workspaceId.Value, cancellationToken);
|
||||
if (membershipList.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var accounts = await userAccounts.ListAsync(cancellationToken);
|
||||
var accountsById = accounts.ToDictionary(account => account.Id);
|
||||
|
||||
IEnumerable<UserAccount> candidates = membershipList
|
||||
.Select(membership => accountsById.GetValueOrDefault(membership.UserId))
|
||||
.Where(account => account is not null && account.Enabled)!;
|
||||
|
||||
var trimmedQuery = query?.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmedQuery))
|
||||
{
|
||||
candidates = candidates.Where(account =>
|
||||
account.DisplayName.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)
|
||||
|| account.Email.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderBy(account => account.DisplayName, StringComparer.Ordinal)
|
||||
.Take(MaxResults)
|
||||
.Select(account => new WorkspaceMemberSearchResult(account.Id, account.DisplayName, account.Email))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity.UseCases;
|
||||
|
||||
public enum UpdateWorkspaceUserStatus
|
||||
{
|
||||
Success,
|
||||
UserNotFound,
|
||||
ValidationFailed,
|
||||
WouldRemoveLastAdministrator
|
||||
}
|
||||
|
||||
public sealed record UpdateWorkspaceUserResult(UpdateWorkspaceUserStatus Status, IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static readonly UpdateWorkspaceUserResult Success = new(UpdateWorkspaceUserStatus.Success, []);
|
||||
|
||||
public static UpdateWorkspaceUserResult Failure(UpdateWorkspaceUserStatus status, IReadOnlyList<string>? errors = null) =>
|
||||
new(status, errors ?? []);
|
||||
}
|
||||
|
||||
/// <summary>Changes a member's role and/or enabled state. Enforces the "at least one
|
||||
/// enabled administrator" invariant (docs/07-domain-model.md) before applying either
|
||||
/// change, since both can remove the last enabled administrator.</summary>
|
||||
public sealed class UpdateWorkspaceUserUseCase(
|
||||
IWorkspaceRepository workspaces,
|
||||
IWorkspaceMembershipRepository memberships,
|
||||
IUserAccountManager userAccounts,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<UpdateWorkspaceUserResult> ExecuteAsync(
|
||||
Guid targetUserId,
|
||||
string? role,
|
||||
bool? enabled,
|
||||
Guid updatedByUserId,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
WorkspaceRole? parsedRole = null;
|
||||
if (role is not null)
|
||||
{
|
||||
if (!Enum.TryParse<WorkspaceRole>(role, ignoreCase: true, out var value))
|
||||
{
|
||||
return UpdateWorkspaceUserResult.Failure(
|
||||
UpdateWorkspaceUserStatus.ValidationFailed,
|
||||
["Onbekende rol."]);
|
||||
}
|
||||
|
||||
parsedRole = value;
|
||||
}
|
||||
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
var account = await userAccounts.FindByIdAsync(targetUserId, cancellationToken);
|
||||
var currentRole = workspaceId is null
|
||||
? null
|
||||
: await memberships.GetRoleAsync(workspaceId.Value, targetUserId, cancellationToken);
|
||||
|
||||
if (workspaceId is null || account is null || currentRole is null)
|
||||
{
|
||||
return UpdateWorkspaceUserResult.Failure(UpdateWorkspaceUserStatus.UserNotFound);
|
||||
}
|
||||
|
||||
var willStayAdministrator = (parsedRole ?? currentRole.Value) == WorkspaceRole.Administrator;
|
||||
var willStayEnabled = enabled ?? account.Enabled;
|
||||
var losesAdministratorCoverage =
|
||||
currentRole.Value == WorkspaceRole.Administrator
|
||||
&& account.Enabled
|
||||
&& !(willStayAdministrator && willStayEnabled);
|
||||
|
||||
if (losesAdministratorCoverage)
|
||||
{
|
||||
var remainingEnabledAdministrators = await CountOtherEnabledAdministratorsAsync(
|
||||
workspaceId.Value,
|
||||
excludingUserId: targetUserId,
|
||||
cancellationToken);
|
||||
|
||||
if (remainingEnabledAdministrators == 0)
|
||||
{
|
||||
return UpdateWorkspaceUserResult.Failure(UpdateWorkspaceUserStatus.WouldRemoveLastAdministrator);
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (parsedRole is not null && parsedRole.Value != currentRole.Value)
|
||||
{
|
||||
await memberships.ChangeRoleAsync(workspaceId.Value, targetUserId, parsedRole.Value, now, cancellationToken);
|
||||
}
|
||||
|
||||
if (enabled is not null && enabled.Value != account.Enabled)
|
||||
{
|
||||
await userAccounts.SetEnabledAsync(targetUserId, enabled.Value, cancellationToken);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
updatedByUserId,
|
||||
Source: "web",
|
||||
Action: "user.updated",
|
||||
TargetType: "user",
|
||||
TargetId: targetUserId.ToString(),
|
||||
Summary: "Gebruikersrol/-status bijgewerkt door beheerder.",
|
||||
correlationId,
|
||||
now),
|
||||
cancellationToken);
|
||||
|
||||
return UpdateWorkspaceUserResult.Success;
|
||||
}
|
||||
|
||||
private async Task<int> CountOtherEnabledAdministratorsAsync(
|
||||
Guid workspaceId,
|
||||
Guid excludingUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membershipList = await memberships.ListAsync(workspaceId, cancellationToken);
|
||||
var administratorIds = membershipList
|
||||
.Where(membership => membership.Role == WorkspaceRole.Administrator && membership.UserId != excludingUserId)
|
||||
.Select(membership => membership.UserId)
|
||||
.ToList();
|
||||
|
||||
var count = 0;
|
||||
foreach (var administratorId in administratorIds)
|
||||
{
|
||||
var administratorAccount = await userAccounts.FindByIdAsync(administratorId, cancellationToken);
|
||||
if (administratorAccount is { Enabled: true })
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
/// <summary>Application-level projection of an interactive local account. Never carries
|
||||
/// the password hash, security stamp or any other secret material.</summary>
|
||||
public sealed record UserAccount(
|
||||
Guid Id,
|
||||
string DisplayName,
|
||||
string Email,
|
||||
bool Enabled,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastLoginAt);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Identity;
|
||||
|
||||
public enum UserAccountCreationStatus
|
||||
{
|
||||
Success,
|
||||
EmailAlreadyInUse,
|
||||
PasswordRejected
|
||||
}
|
||||
|
||||
public sealed record UserAccountCreationResult(
|
||||
UserAccountCreationStatus Status,
|
||||
UserAccount? Account,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static UserAccountCreationResult Success(UserAccount account) =>
|
||||
new(UserAccountCreationStatus.Success, account, []);
|
||||
|
||||
public static UserAccountCreationResult EmailAlreadyInUse() =>
|
||||
new(UserAccountCreationStatus.EmailAlreadyInUse, null, ["Dit e-mailadres is al in gebruik."]);
|
||||
|
||||
public static UserAccountCreationResult PasswordRejected(IReadOnlyList<string> errors) =>
|
||||
new(UserAccountCreationStatus.PasswordRejected, null, errors);
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure;
|
||||
|
||||
public sealed record InfrastructureServiceGraph(
|
||||
InfrastructureService Service,
|
||||
EditorialProfile? EditorialProfile,
|
||||
string EditorialETag,
|
||||
IReadOnlyList<ManualOverride> Overrides,
|
||||
IReadOnlyList<ServiceComponent> Components,
|
||||
IReadOnlyList<InfrastructureEndpoint> Endpoints,
|
||||
IReadOnlyList<InfrastructureNetwork> Networks,
|
||||
IReadOnlyList<InfrastructureVolume> Volumes,
|
||||
IReadOnlyList<InfrastructureDatabase> Databases,
|
||||
IReadOnlyList<ServiceDependency> Dependencies,
|
||||
IReadOnlyList<HealthObservation> HealthObservations,
|
||||
IReadOnlyList<DeploymentReference> Deployments,
|
||||
IReadOnlyList<BackupCoverage> Backups);
|
||||
|
||||
/// <summary>A catalog-list row with only what a summary view actually renders —
|
||||
/// counts, not the full related-fact rows <see cref="InfrastructureServiceGraph"/>
|
||||
/// carries for a single service's detail page
|
||||
/// (docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, V2-R2).
|
||||
/// </summary>
|
||||
public sealed record InfrastructureServiceSummaryRow(
|
||||
InfrastructureService Service,
|
||||
EditorialProfile? EditorialProfile,
|
||||
string? ProjectName,
|
||||
string? PrimaryEndpointUrl,
|
||||
int ComponentCount,
|
||||
int EndpointCount,
|
||||
int VolumeCount,
|
||||
int DatabaseCount);
|
||||
|
||||
/// <summary>One cursor page of the service catalog, plus how many services the
|
||||
/// current filters match in total. <paramref name="TotalCount"/> is deliberately the
|
||||
/// count of the *filtered* catalog rather than of the returned page: an operator
|
||||
/// filtering 80 services down to 6 needs to see "6", and needs to see it before
|
||||
/// paging to the end.</summary>
|
||||
public sealed record InfrastructureServiceSummaryPage(
|
||||
IReadOnlyList<InfrastructureServiceSummaryRow> Items, string? NextCursor, int TotalCount);
|
||||
|
||||
public enum ServiceCatalogStatus { Attention, Healthy, Missing }
|
||||
|
||||
/// <summary>A single volume plus just enough about its owning service to render a
|
||||
/// storage row — not the service's other eight fact types
|
||||
/// (V2-R2, same over-fetch class of finding as
|
||||
/// <see cref="InfrastructureServiceSummaryRow"/>). <paramref name="ServiceDisplayName"/>
|
||||
/// already applies the same editorial-display-name-over-detected-name preference
|
||||
/// <c>InfrastructureEndpoints.ToSummary</c> uses elsewhere, so the storage view's
|
||||
/// service names never silently regress to the raw detected name.</summary>
|
||||
public sealed record InfrastructureVolumeRow(
|
||||
InfrastructureService Service, InfrastructureVolume Volume, string ServiceDisplayName);
|
||||
|
||||
/// <summary>Cursor-paginated storage listing (V2-R5,
|
||||
/// docs/adr/ADR-029-v2-r5-final-hardening.md) — the same `{ items, nextCursor }` shape
|
||||
/// already established for services (V2-R2). Previously an unbounded, unpaginated
|
||||
/// full-table listing.</summary>
|
||||
public sealed record InfrastructureVolumePage(IReadOnlyList<InfrastructureVolumeRow> Items, string? NextCursor);
|
||||
|
||||
public sealed record InfrastructureOverviewData(
|
||||
InventorySnapshot? LatestSnapshot,
|
||||
IReadOnlyList<InfrastructureHost> Hosts,
|
||||
int ServiceCount,
|
||||
int ProposedServiceCount,
|
||||
int MissingServiceCount,
|
||||
int UnhealthyServiceCount,
|
||||
int UnprotectedServiceCount);
|
||||
|
||||
public sealed record InventoryHostInput(
|
||||
string SourceKey,
|
||||
string Hostname,
|
||||
string? OperatingSystem,
|
||||
string? KernelVersion,
|
||||
string? Architecture,
|
||||
int LogicalCpuCount,
|
||||
long TotalMemoryBytes);
|
||||
|
||||
public sealed record InventoryComponentInput(
|
||||
string SourceKey, string Name, string? Kind, string? RuntimeId, string? Status, string? ImageReference, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryEndpointInput(
|
||||
string SourceKey, string? Scheme, string? Host, int? PublishedPort, int? TargetPort, string? Url, bool IsPublic, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryNetworkInput(
|
||||
string SourceKey, string Name, string? Driver, string? Scope, bool Internal, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryVolumeInput(
|
||||
string SourceKey, string Name, string? MountPath, string? StorageClass, bool ReadOnly, long? SizeBytes, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryDatabaseInput(
|
||||
string SourceKey, string Name, string? Engine, string? Version, string? Host, int? Port, bool HostPortPublished, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryDependencyInput(
|
||||
string SourceKey, string DependsOnSourceKey, string? Kind, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryHealthInput(
|
||||
string SourceKey, HealthState State, string? CheckKind, int? ResponseTimeMilliseconds, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryDeploymentInput(
|
||||
string SourceKey, string? RepositoryUrl, string? Commit, string? ImageReference, string? ImageDigest,
|
||||
DateTimeOffset? DeployedAt, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryBackupInput(
|
||||
string SourceKey, string? Method, string? Schedule, DateTimeOffset? LastSuccessfulAt, bool RestoreTested, FactConfidence Confidence);
|
||||
|
||||
public sealed record InventoryServiceInput(
|
||||
string SourceKey,
|
||||
string? HostSourceKey,
|
||||
string DetectedName,
|
||||
string? RuntimeKind,
|
||||
string? RuntimeStatus,
|
||||
HealthState Health,
|
||||
string? ImageReference,
|
||||
string? ImageDigest,
|
||||
string? RuntimeUser,
|
||||
bool ReadOnlyRootFilesystem,
|
||||
bool NoNewPrivileges,
|
||||
bool Privileged,
|
||||
FactConfidence Confidence,
|
||||
IReadOnlyList<InventoryComponentInput> Components,
|
||||
IReadOnlyList<InventoryEndpointInput> Endpoints,
|
||||
IReadOnlyList<InventoryNetworkInput> Networks,
|
||||
IReadOnlyList<InventoryVolumeInput> Volumes,
|
||||
IReadOnlyList<InventoryDatabaseInput> Databases,
|
||||
IReadOnlyList<InventoryDependencyInput> Dependencies,
|
||||
IReadOnlyList<InventoryHealthInput> HealthObservations,
|
||||
IReadOnlyList<InventoryDeploymentInput> Deployments,
|
||||
IReadOnlyList<InventoryBackupInput> Backups);
|
||||
|
||||
public sealed record InventorySnapshotInput(
|
||||
string SourceKey,
|
||||
string SourceHash,
|
||||
DateTimeOffset CapturedAt,
|
||||
IReadOnlyList<InventoryHostInput> Hosts,
|
||||
IReadOnlyList<InventoryServiceInput> Services);
|
||||
|
||||
public sealed record InventorySyncResult(
|
||||
Guid SnapshotId,
|
||||
bool AlreadyImported,
|
||||
int AddedServices,
|
||||
int UpdatedServices,
|
||||
int MissingServices,
|
||||
int PreservedEditorialProfiles,
|
||||
int PreservedOverrides);
|
||||
|
||||
public interface IInfrastructureRepository
|
||||
{
|
||||
Task<InfrastructureOverviewData> GetOverviewAsync(Guid workspaceId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Every service's full related-fact graph, unpaginated — kept only for
|
||||
/// the curated-import package matcher (<c>ImportCuratedInventoryPackageUseCase</c>),
|
||||
/// an infrequent, admin-triggered, already inventory-size-bounded batch operation
|
||||
/// that genuinely needs the whole set. Never call this from an HTTP list endpoint —
|
||||
/// use <see cref="ListServiceSummariesAsync"/> instead (V2-R2, docs/adr/ADR-026-
|
||||
/// v2-r2-infrastructure-scale-and-sync-durability.md).</summary>
|
||||
Task<IReadOnlyList<InfrastructureServiceGraph>> ListServicesAsync(
|
||||
Guid workspaceId, bool includeHidden, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Cursor-paginated, summary-only catalog listing (V2-R2): pages the
|
||||
/// service rows first, then fetches only editorial profiles and fact *counts* for
|
||||
/// the services on that one page — never every related fact row for every service
|
||||
/// in the workspace, regardless of workspace size. <paramref name="projectId"/>
|
||||
/// (V2-R5) is applied server-side on the same paginated query — the fix for the
|
||||
/// Project workspace previously fetching up to 200 services and filtering by
|
||||
/// project client-side (docs/adr/ADR-029-v2-r5-final-hardening.md).</summary>
|
||||
Task<InfrastructureServiceSummaryPage> ListServiceSummariesAsync(
|
||||
Guid workspaceId, bool includeHidden, Guid? projectId, string? search, ServiceCatalogStatus? status, string? cursor, int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Cursor-paginated storage listing (V2-R5) — every volume across the
|
||||
/// workspace's services, only volumes and each owning service's display fields, not
|
||||
/// the other eight fact types a full <see cref="InfrastructureServiceGraph"/>
|
||||
/// carries (V2-R2). <paramref name="search"/> matches case-insensitively against
|
||||
/// the owning service's detected name and the volume's own name/mount path,
|
||||
/// server-side — never only within an already-loaded page.</summary>
|
||||
Task<InfrastructureVolumePage> ListVolumesAsync(
|
||||
Guid workspaceId, string? search, string? cursor, int limit, CancellationToken cancellationToken);
|
||||
|
||||
Task<InfrastructureServiceGraph?> GetServiceAsync(Guid workspaceId, Guid serviceId, CancellationToken cancellationToken);
|
||||
|
||||
Task<InfrastructureService?> FindServiceForReviewAsync(
|
||||
Guid workspaceId, Guid serviceId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<InventoryChange>> ListChangesAsync(
|
||||
Guid workspaceId, int limit, CancellationToken cancellationToken);
|
||||
|
||||
Task<InventorySyncResult> ApplySnapshotAsync(
|
||||
Guid workspaceId, InventorySnapshotInput input, DateTimeOffset importedAt, CancellationToken cancellationToken);
|
||||
|
||||
Task<EditorialProfile?> FindEditorialProfileForUpdateAsync(
|
||||
Guid workspaceId, InfrastructureObjectKind targetKind, Guid targetId, string expectedETag, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> TargetExistsAsync(
|
||||
Guid workspaceId, InfrastructureObjectKind targetKind, Guid targetId, CancellationToken cancellationToken);
|
||||
|
||||
void AddEditorialProfile(EditorialProfile profile);
|
||||
|
||||
Task<ManualOverride?> FindOverrideAsync(
|
||||
Guid workspaceId, InfrastructureObjectKind targetKind, Guid targetId, string fieldName, CancellationToken cancellationToken);
|
||||
|
||||
void AddOverride(ManualOverride manualOverride);
|
||||
|
||||
Task RemoveOverrideAsync(Guid workspaceId, Guid overrideId, CancellationToken cancellationToken);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<string?> GetEditorialETagAsync(Guid profileId, CancellationToken cancellationToken);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
|
||||
public sealed record CuratedServiceProfileInput(
|
||||
string SourceKey,
|
||||
string? DisplayName,
|
||||
string? Summary,
|
||||
string? Category,
|
||||
string? Purpose,
|
||||
string? RepositoryUrl,
|
||||
string? LiveUrl,
|
||||
IReadOnlyList<string> Tags,
|
||||
bool Reviewed,
|
||||
bool Archived);
|
||||
|
||||
public sealed record ImportCuratedInventoryPackageCommand(
|
||||
Guid WorkspaceId,
|
||||
InventorySnapshotInput Inventory,
|
||||
IReadOnlyList<CuratedServiceProfileInput> Services,
|
||||
Guid ActorId,
|
||||
string? CorrelationId);
|
||||
|
||||
public sealed record CuratedInventoryImportResult(
|
||||
InventorySyncResult Inventory,
|
||||
int CreatedEditorialProfiles,
|
||||
int ConfirmedServices,
|
||||
int ArchivedServices);
|
||||
|
||||
public sealed class ImportCuratedInventoryPackageUseCase(
|
||||
IInfrastructureRepository infrastructure,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResultValue<CuratedInventoryImportResult>> ExecuteAsync(
|
||||
ImportCuratedInventoryPackageCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inventoryKeys = command.Inventory.Services
|
||||
.Select(service => service.SourceKey.Trim())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var duplicateProfiles = command.Services
|
||||
.GroupBy(profile => profile.SourceKey.Trim(), StringComparer.Ordinal)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicateProfiles || command.Services.Any(profile => !inventoryKeys.Contains(profile.SourceKey.Trim())))
|
||||
{
|
||||
return OperationResultValue.Failure<CuratedInventoryImportResult>(
|
||||
OperationStatus.ValidationFailed,
|
||||
["Elk redactioneel profiel moet uniek zijn en naar een service in dezelfde inventarissnapshot verwijzen."]);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var sync = await infrastructure.ApplySnapshotAsync(
|
||||
command.WorkspaceId, command.Inventory, now, cancellationToken);
|
||||
var graphs = await infrastructure.ListServicesAsync(
|
||||
command.WorkspaceId, includeHidden: true, cancellationToken);
|
||||
var bySource = graphs.ToDictionary(graph => graph.Service.SourceKey, StringComparer.Ordinal);
|
||||
var createdProfiles = 0;
|
||||
var confirmed = 0;
|
||||
var archived = 0;
|
||||
|
||||
foreach (var input in command.Services)
|
||||
{
|
||||
var graph = bySource[input.SourceKey.Trim()];
|
||||
var service = await infrastructure.FindServiceForReviewAsync(
|
||||
command.WorkspaceId, graph.Service.Id, cancellationToken);
|
||||
if (service is null)
|
||||
{
|
||||
return OperationResultValue.Failure<CuratedInventoryImportResult>(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
if (input.Archived && service.Presence == InventoryPresence.Proposed)
|
||||
{
|
||||
service.Reject();
|
||||
archived++;
|
||||
}
|
||||
else if (input.Reviewed && service.Presence == InventoryPresence.Proposed)
|
||||
{
|
||||
service.Confirm();
|
||||
confirmed++;
|
||||
}
|
||||
|
||||
if (graph.EditorialProfile is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var profile = EditorialProfile.Create(
|
||||
command.WorkspaceId, InfrastructureObjectKind.Service, service.Id, command.ActorId, now);
|
||||
profile.Update(
|
||||
input.DisplayName, input.Summary, input.Category, input.Purpose,
|
||||
null, null, input.RepositoryUrl, input.LiveUrl, null, null,
|
||||
null, null, null, null, input.Tags,
|
||||
false, false, input.Archived, command.ActorId, now);
|
||||
infrastructure.AddEditorialProfile(profile);
|
||||
createdProfiles++;
|
||||
}
|
||||
|
||||
await infrastructure.SaveChangesAsync(cancellationToken);
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
command.ActorId,
|
||||
"web",
|
||||
"infrastructure.curated-package-imported",
|
||||
"inventory-snapshot",
|
||||
sync.SnapshotId.ToString(),
|
||||
$"Gecureerde inventaris verwerkt: {createdProfiles} profielen, {confirmed} bevestigd, {archived} gearchiveerd.",
|
||||
command.CorrelationId,
|
||||
now),
|
||||
cancellationToken);
|
||||
|
||||
return OperationResultValue.Success(
|
||||
new CuratedInventoryImportResult(sync, createdProfiles, confirmed, archived));
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
|
||||
public sealed class GetInfrastructureOverviewUseCase(IInfrastructureRepository infrastructure)
|
||||
{
|
||||
public Task<InfrastructureOverviewData> ExecuteAsync(Guid workspaceId, CancellationToken cancellationToken) =>
|
||||
infrastructure.GetOverviewAsync(workspaceId, cancellationToken);
|
||||
}
|
||||
/// <summary>Unpaginated full-graph listing — reserved for the curated-import matcher
|
||||
/// (see <see cref="IInfrastructureRepository.ListServicesAsync"/>'s own doc comment).
|
||||
/// Not used by any HTTP endpoint since V2-R2; kept for that one internal caller.
|
||||
/// </summary>
|
||||
public sealed class ListInfrastructureServicesUseCase(IInfrastructureRepository infrastructure)
|
||||
{
|
||||
public Task<IReadOnlyList<InfrastructureServiceGraph>> ExecuteAsync(
|
||||
Guid workspaceId, bool includeHidden, CancellationToken cancellationToken) =>
|
||||
infrastructure.ListServicesAsync(workspaceId, includeHidden, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Cursor-paginated, summary-only catalog listing (V2-R2,
|
||||
/// docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md) — what
|
||||
/// `GET /infrastructure/services` actually uses now.</summary>
|
||||
public sealed class ListInfrastructureServiceSummariesUseCase(IInfrastructureRepository infrastructure)
|
||||
{
|
||||
public Task<InfrastructureServiceSummaryPage> ExecuteAsync(
|
||||
Guid workspaceId, bool includeHidden, Guid? projectId, string? search, string? status, string? cursor, int limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
infrastructure.ListServiceSummariesAsync(
|
||||
workspaceId, includeHidden, projectId, search,
|
||||
Enum.TryParse<ServiceCatalogStatus>(status, true, out var parsedStatus) ? parsedStatus : null,
|
||||
cursor, limit, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Cursor-paginated, lean-projected storage listing (V2-R2 fields, V2-R5
|
||||
/// pagination) — what `GET /infrastructure/storage` actually uses now.</summary>
|
||||
public sealed class ListInfrastructureVolumesUseCase(IInfrastructureRepository infrastructure)
|
||||
{
|
||||
public Task<InfrastructureVolumePage> ExecuteAsync(
|
||||
Guid workspaceId, string? search, string? cursor, int limit, CancellationToken cancellationToken) =>
|
||||
infrastructure.ListVolumesAsync(workspaceId, search, cursor, limit, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class GetInfrastructureServiceUseCase(IInfrastructureRepository infrastructure)
|
||||
{
|
||||
public Task<InfrastructureServiceGraph?> ExecuteAsync(
|
||||
Guid workspaceId, Guid serviceId, CancellationToken cancellationToken) =>
|
||||
infrastructure.GetServiceAsync(workspaceId, serviceId, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ListInfrastructureChangesUseCase(IInfrastructureRepository infrastructure)
|
||||
{
|
||||
public Task<IReadOnlyList<Domain.Infrastructure.InventoryChange>> ExecuteAsync(
|
||||
Guid workspaceId, int limit, CancellationToken cancellationToken) =>
|
||||
infrastructure.ListChangesAsync(workspaceId, Math.Clamp(limit, 1, 500), cancellationToken);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Infrastructure.Validation;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
|
||||
public sealed record SetManualOverrideCommand(
|
||||
Guid WorkspaceId, InfrastructureObjectKind TargetKind, Guid TargetId, string FieldName,
|
||||
string ValueJson, string? Reason, Guid ActorId, string? CorrelationId);
|
||||
|
||||
public sealed class SetManualOverrideUseCase(
|
||||
IInfrastructureRepository infrastructure,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResultValue<Guid>> ExecuteAsync(SetManualOverrideCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.FieldName) || string.IsNullOrWhiteSpace(command.ValueJson))
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, ["Veldnaam en overridewaarde zijn verplicht."]);
|
||||
}
|
||||
|
||||
JsonElement value;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(command.ValueJson);
|
||||
value = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, ["Overridewaarde is geen geldige JSON."]);
|
||||
}
|
||||
|
||||
var catalogErrors = ManualOverrideCatalog.Validate(command.TargetKind, command.FieldName.Trim(), value, command.Reason);
|
||||
if (catalogErrors.Count > 0)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, catalogErrors);
|
||||
}
|
||||
|
||||
if (!await infrastructure.TargetExistsAsync(command.WorkspaceId, command.TargetKind, command.TargetId, cancellationToken))
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var manualOverride = await infrastructure.FindOverrideAsync(
|
||||
command.WorkspaceId, command.TargetKind, command.TargetId, command.FieldName.Trim(), cancellationToken);
|
||||
|
||||
if (manualOverride is null)
|
||||
{
|
||||
manualOverride = ManualOverride.Create(
|
||||
command.WorkspaceId, command.TargetKind, command.TargetId, command.FieldName,
|
||||
command.ValueJson, command.Reason, command.ActorId, now);
|
||||
infrastructure.AddOverride(manualOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
manualOverride.Replace(command.ValueJson, command.Reason, command.ActorId, now);
|
||||
}
|
||||
|
||||
await infrastructure.SaveChangesAsync(cancellationToken);
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, "web", "infrastructure.override-set",
|
||||
command.TargetKind.ToString().ToLowerInvariant(), command.TargetId.ToString(),
|
||||
$"Handmatige correctie voor veld '{command.FieldName.Trim()}' opgeslagen.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
return OperationResultValue.Success(manualOverride.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RemoveManualOverrideUseCase(
|
||||
IInfrastructureRepository infrastructure,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResult> ExecuteAsync(
|
||||
Guid workspaceId, Guid overrideId, Guid actorId, string? correlationId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await infrastructure.RemoveOverrideAsync(workspaceId, overrideId, cancellationToken);
|
||||
await infrastructure.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return OperationResult.Failure(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, actorId, "web", "infrastructure.override-removed",
|
||||
"manual-override", overrideId.ToString(), "Handmatige correctie verwijderd.",
|
||||
correlationId, timeProvider.GetUtcNow()),
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult.Success();
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
|
||||
public enum InfrastructureServiceReviewDecision
|
||||
{
|
||||
Accept,
|
||||
Reject
|
||||
}
|
||||
|
||||
public sealed record ReviewInfrastructureServiceCommand(
|
||||
Guid WorkspaceId,
|
||||
Guid ServiceId,
|
||||
InfrastructureServiceReviewDecision Decision,
|
||||
string? Reason,
|
||||
Guid ActorId,
|
||||
string? CorrelationId);
|
||||
|
||||
public sealed class ReviewInfrastructureServiceUseCase(
|
||||
IInfrastructureRepository infrastructure,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResultValue<InventoryPresence>> ExecuteAsync(
|
||||
ReviewInfrastructureServiceCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var service = await infrastructure.FindServiceForReviewAsync(
|
||||
command.WorkspaceId, command.ServiceId, cancellationToken);
|
||||
if (service is null)
|
||||
{
|
||||
return OperationResultValue.Failure<InventoryPresence>(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var targetPresence = command.Decision == InfrastructureServiceReviewDecision.Accept
|
||||
? InventoryPresence.Present
|
||||
: InventoryPresence.Archived;
|
||||
if (service.Presence == targetPresence)
|
||||
{
|
||||
return OperationResultValue.Success(targetPresence);
|
||||
}
|
||||
|
||||
if (service.Presence != InventoryPresence.Proposed)
|
||||
{
|
||||
return OperationResultValue.Failure<InventoryPresence>(
|
||||
OperationStatus.ValidationFailed,
|
||||
["Alleen een voorgestelde service kan worden bevestigd of afgewezen."]);
|
||||
}
|
||||
|
||||
if (command.Reason?.Trim().Length > 500)
|
||||
{
|
||||
return OperationResultValue.Failure<InventoryPresence>(
|
||||
OperationStatus.ValidationFailed,
|
||||
["De reden mag maximaal 500 tekens bevatten."]);
|
||||
}
|
||||
|
||||
if (command.Decision == InfrastructureServiceReviewDecision.Accept)
|
||||
{
|
||||
service.Confirm();
|
||||
}
|
||||
else
|
||||
{
|
||||
service.Reject();
|
||||
}
|
||||
|
||||
await infrastructure.SaveChangesAsync(cancellationToken);
|
||||
var accepted = command.Decision == InfrastructureServiceReviewDecision.Accept;
|
||||
var decision = accepted ? "bevestigd" : "afgewezen";
|
||||
var reason = string.IsNullOrWhiteSpace(command.Reason) ? string.Empty : $" Reden: {command.Reason.Trim()}";
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User,
|
||||
command.ActorId,
|
||||
"web",
|
||||
accepted ? "infrastructure.service-accepted" : "infrastructure.service-rejected",
|
||||
"service",
|
||||
service.Id.ToString(),
|
||||
$"Voorgestelde service '{service.SourceKey}' is {decision}.{reason}",
|
||||
command.CorrelationId,
|
||||
timeProvider.GetUtcNow()),
|
||||
cancellationToken);
|
||||
|
||||
return OperationResultValue.Success(service.Presence);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
|
||||
public sealed record SynchronizeInfrastructureInventoryCommand(
|
||||
Guid WorkspaceId,
|
||||
InventorySnapshotInput Snapshot,
|
||||
Guid? ActorId,
|
||||
AuditActorKind ActorKind,
|
||||
string Source,
|
||||
string? CorrelationId);
|
||||
|
||||
public sealed class SynchronizeInfrastructureInventoryUseCase(
|
||||
IInfrastructureRepository infrastructure,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResultValue<InventorySyncResult>> ExecuteAsync(
|
||||
SynchronizeInfrastructureInventoryCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Snapshot.SourceKey)
|
||||
|| string.IsNullOrWhiteSpace(command.Snapshot.SourceHash))
|
||||
{
|
||||
return OperationResultValue.Failure<InventorySyncResult>(
|
||||
OperationStatus.ValidationFailed, ["Bron-ID en bronhash zijn verplicht."]);
|
||||
}
|
||||
|
||||
if (HasDuplicateKeys(command.Snapshot.Hosts.Select(host => host.SourceKey))
|
||||
|| HasDuplicateKeys(command.Snapshot.Services.Select(service => service.SourceKey)))
|
||||
{
|
||||
return OperationResultValue.Failure<InventorySyncResult>(
|
||||
OperationStatus.ValidationFailed, ["De inventaris bevat dubbele bron-ID's."]);
|
||||
}
|
||||
|
||||
var result = await infrastructure.ApplySnapshotAsync(
|
||||
command.WorkspaceId, command.Snapshot, timeProvider.GetUtcNow(), cancellationToken);
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
command.ActorKind, command.ActorId, command.Source, "infrastructure.inventory-synchronized",
|
||||
"inventory-snapshot", result.SnapshotId.ToString(),
|
||||
result.AlreadyImported
|
||||
? $"Inventarissnapshot '{command.Snapshot.SourceKey}' idempotent herkend."
|
||||
: $"Inventarissnapshot '{command.Snapshot.SourceKey}' verwerkt: {result.AddedServices} nieuw, {result.UpdatedServices} bijgewerkt, {result.MissingServices} ontbrekend.",
|
||||
command.CorrelationId, timeProvider.GetUtcNow()),
|
||||
cancellationToken);
|
||||
|
||||
return OperationResultValue.Success(result);
|
||||
}
|
||||
|
||||
private static bool HasDuplicateKeys(IEnumerable<string> keys)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
return keys.Any(key => string.IsNullOrWhiteSpace(key) || !seen.Add(key.Trim()));
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Infrastructure.Validation;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.UseCases;
|
||||
|
||||
public sealed record UpdateInfrastructureEditorialProfileCommand(
|
||||
Guid WorkspaceId,
|
||||
InfrastructureObjectKind TargetKind,
|
||||
Guid TargetId,
|
||||
string? DisplayName,
|
||||
string? Summary,
|
||||
string? Category,
|
||||
string? Purpose,
|
||||
Guid? OwnerId,
|
||||
Guid? ProjectId,
|
||||
string? RepositoryUrl,
|
||||
string? LiveUrl,
|
||||
string? BackupStatus,
|
||||
string? MonitoringUrl,
|
||||
string? MaintenanceNotes,
|
||||
string? KnownRisks,
|
||||
string? UpdateProcedure,
|
||||
string? RollbackProcedure,
|
||||
IReadOnlyList<string>? Tags,
|
||||
bool IsFavorite,
|
||||
bool IsHidden,
|
||||
bool IsArchived,
|
||||
string ExpectedETag,
|
||||
Guid ActorId,
|
||||
string? CorrelationId);
|
||||
|
||||
public sealed class UpdateInfrastructureEditorialProfileUseCase(
|
||||
IInfrastructureRepository infrastructure,
|
||||
EditorialProfileValidator validator,
|
||||
IAuditEventRecorder auditEvents,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<OperationResultValue<Guid>> ExecuteAsync(
|
||||
UpdateInfrastructureEditorialProfileCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await infrastructure.TargetExistsAsync(
|
||||
command.WorkspaceId, command.TargetKind, command.TargetId, cancellationToken))
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.NotFound);
|
||||
}
|
||||
|
||||
var errors = await validator.ValidateAsync(
|
||||
command.WorkspaceId, command.DisplayName, command.Summary, command.Category, command.Purpose,
|
||||
command.OwnerId, command.ProjectId, command.RepositoryUrl, command.LiveUrl, command.BackupStatus,
|
||||
command.MonitoringUrl, command.MaintenanceNotes, command.KnownRisks, command.UpdateProcedure,
|
||||
command.RollbackProcedure, command.Tags, cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ValidationFailed, errors);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var profile = await infrastructure.FindEditorialProfileForUpdateAsync(
|
||||
command.WorkspaceId, command.TargetKind, command.TargetId, command.ExpectedETag, cancellationToken);
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
if (!string.Equals(command.ExpectedETag, "0", StringComparison.Ordinal))
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
profile = EditorialProfile.Create(command.WorkspaceId, command.TargetKind, command.TargetId, command.ActorId, now);
|
||||
infrastructure.AddEditorialProfile(profile);
|
||||
}
|
||||
|
||||
profile.Update(
|
||||
command.DisplayName, command.Summary, command.Category, command.Purpose, command.OwnerId, command.ProjectId,
|
||||
command.RepositoryUrl, command.LiveUrl, command.BackupStatus, command.MonitoringUrl,
|
||||
command.MaintenanceNotes, command.KnownRisks, command.UpdateProcedure, command.RollbackProcedure,
|
||||
command.Tags, command.IsFavorite, command.IsHidden, command.IsArchived, command.ActorId, now);
|
||||
|
||||
try
|
||||
{
|
||||
await infrastructure.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (ConcurrencyConflictException)
|
||||
{
|
||||
return OperationResultValue.Failure<Guid>(OperationStatus.ConcurrencyConflict);
|
||||
}
|
||||
|
||||
await auditEvents.RecordAsync(
|
||||
new AuditEventRecord(
|
||||
AuditActorKind.User, command.ActorId, "web", "infrastructure.editorial-profile-updated",
|
||||
command.TargetKind.ToString().ToLowerInvariant(), command.TargetId.ToString(),
|
||||
"Redactioneel infrastructuurprofiel bijgewerkt.", command.CorrelationId, now),
|
||||
cancellationToken);
|
||||
|
||||
var etag = await infrastructure.GetEditorialETagAsync(profile.Id, cancellationToken);
|
||||
return OperationResultValue.Success(profile.Id, etag);
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using ItWorx.KnowledgeHub.Application.Common;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Projects;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized server-side validation for
|
||||
/// <see cref="Domain.Infrastructure.EditorialProfile"/> writes
|
||||
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2).
|
||||
/// Database column max-lengths (see
|
||||
/// <c>EditorialKnowledgeConfigurations.EditorialProfileConfiguration</c>) remain
|
||||
/// defense in depth — this is the primary validator, run *before* anything reaches
|
||||
/// EF Core, so invalid input always comes back as a specific RFC 7807 422, never a
|
||||
/// database exception. Kept as its own small class (not inlined into the use case) so
|
||||
/// its every-field-and-boundary shape has one obvious place to extend when a new
|
||||
/// editorial field is added.
|
||||
/// </summary>
|
||||
public sealed class EditorialProfileValidator(
|
||||
IWorkspaceMembershipRepository memberships, IProjectRepository projects)
|
||||
{
|
||||
// English tokens, matching this codebase's established enum-as-string convention
|
||||
// (DocumentStatus, RunbookStatus, ...): the frontend's <select> options carry
|
||||
// these values and translate them to Dutch labels centrally via
|
||||
// statusLabels.ts — see V2_EVIDENCE.md's V2-R1 section for the chosen vocabulary.
|
||||
public static readonly IReadOnlyList<string> AllowedCategories =
|
||||
["Infrastructure", "Networking", "Storage", "Security", "Monitoring", "Automation", "Knowledge", "Media", "Productivity", "Development", "Backup", "Other"];
|
||||
|
||||
public static readonly IReadOnlyList<string> AllowedBackupStatuses =
|
||||
["Unknown", "None", "Manual", "Automated", "AutomatedTested"];
|
||||
|
||||
public const int DisplayNameMaxLength = 300;
|
||||
public const int SummaryMaxLength = 4000;
|
||||
public const int PurposeMaxLength = 4000;
|
||||
public const int UrlMaxLength = 2000;
|
||||
public const int LongTextMaxLength = 20000;
|
||||
public const int MaxTagCount = 25;
|
||||
public const int MaxTagLength = 64;
|
||||
|
||||
public async Task<IReadOnlyList<string>> ValidateAsync(
|
||||
Guid workspaceId,
|
||||
string? displayName,
|
||||
string? summary,
|
||||
string? category,
|
||||
string? purpose,
|
||||
Guid? ownerId,
|
||||
Guid? projectId,
|
||||
string? repositoryUrl,
|
||||
string? liveUrl,
|
||||
string? backupStatus,
|
||||
string? monitoringUrl,
|
||||
string? maintenanceNotes,
|
||||
string? knownRisks,
|
||||
string? updateProcedure,
|
||||
string? rollbackProcedure,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
AddLengthError(errors, displayName, "Menselijke naam", DisplayNameMaxLength);
|
||||
AddLengthError(errors, summary, "Samenvatting", SummaryMaxLength);
|
||||
AddLengthError(errors, purpose, "Doel", PurposeMaxLength);
|
||||
AddLengthError(errors, maintenanceNotes, "Onderhoudsnotities", LongTextMaxLength);
|
||||
AddLengthError(errors, knownRisks, "Bekende risico's", LongTextMaxLength);
|
||||
AddLengthError(errors, updateProcedure, "Updateprocedure", LongTextMaxLength);
|
||||
AddLengthError(errors, rollbackProcedure, "Rollbackprocedure", LongTextMaxLength);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(category) && !AllowedCategories.Contains(category, StringComparer.Ordinal))
|
||||
{
|
||||
errors.Add($"Categorie moet een van de volgende zijn: {string.Join(", ", AllowedCategories)}.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupStatus) && !AllowedBackupStatuses.Contains(backupStatus, StringComparer.Ordinal))
|
||||
{
|
||||
errors.Add($"Back-upstatus moet een van de volgende zijn: {string.Join(", ", AllowedBackupStatuses)}.");
|
||||
}
|
||||
|
||||
AddIfNotNull(errors, SafeUrlValidator.Validate(repositoryUrl, "Repository-URL", UrlMaxLength));
|
||||
AddIfNotNull(errors, SafeUrlValidator.Validate(liveUrl, "Live-URL", UrlMaxLength));
|
||||
AddIfNotNull(errors, SafeUrlValidator.Validate(monitoringUrl, "Monitoring-URL", UrlMaxLength));
|
||||
|
||||
ValidateTags(errors, tags);
|
||||
|
||||
if (ownerId is { } owner)
|
||||
{
|
||||
var role = await memberships.GetRoleAsync(workspaceId, owner, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
errors.Add("Eigenaar is geen lid van deze workspace.");
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId is { } project)
|
||||
{
|
||||
var found = await projects.FindAsync(project, cancellationToken);
|
||||
if (found is null || found.WorkspaceId != workspaceId)
|
||||
{
|
||||
errors.Add("Gekoppeld project bestaat niet binnen deze workspace.");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void ValidateTags(List<string> errors, IReadOnlyList<string>? tags)
|
||||
{
|
||||
if (tags is null || tags.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = tags.Select(tag => tag.Trim()).Where(tag => tag.Length > 0).ToList();
|
||||
if (trimmed.Count > MaxTagCount)
|
||||
{
|
||||
errors.Add($"Maximaal {MaxTagCount} labels toegestaan.");
|
||||
}
|
||||
|
||||
if (trimmed.Any(tag => tag.Length > MaxTagLength))
|
||||
{
|
||||
errors.Add($"Elk label mag maximaal {MaxTagLength} tekens bevatten.");
|
||||
}
|
||||
|
||||
var duplicate = trimmed
|
||||
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.Any(group => group.Count() > 1);
|
||||
if (duplicate)
|
||||
{
|
||||
errors.Add("Labels mogen niet dubbel voorkomen.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddLengthError(List<string> errors, string? value, string fieldLabel, int maxLength)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value) && value.Length > maxLength)
|
||||
{
|
||||
errors.Add($"{fieldLabel} mag maximaal {maxLength} tekens bevatten.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfNotNull(List<string> errors, string? error)
|
||||
{
|
||||
if (error is not null)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Infrastructure.Validation;
|
||||
|
||||
public enum ManualOverrideValueKind
|
||||
{
|
||||
Text,
|
||||
Enum
|
||||
}
|
||||
|
||||
/// <summary>One overridable field, per <see cref="InfrastructureObjectKind"/>
|
||||
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H3). This is
|
||||
/// the single place the set of overridable fields is defined — the frontend fetches
|
||||
/// it from <c>GET /api/v1/infrastructure/overrides/catalog</c> instead of keeping its
|
||||
/// own separate hardcoded list, so the two can never independently drift apart.
|
||||
/// </summary>
|
||||
public sealed record ManualOverrideFieldDefinition(
|
||||
string FieldName,
|
||||
string DisplayLabel,
|
||||
ManualOverrideValueKind ValueKind,
|
||||
int MaxStringLength,
|
||||
IReadOnlyList<string>? AllowedEnumValues);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces that a <see cref="Domain.Infrastructure.ManualOverride"/> can only ever
|
||||
/// carry a field name, JSON type and size this catalog explicitly allows for its
|
||||
/// target's kind — never an arbitrary field name or unbounded JSON (audit finding
|
||||
/// H3: "ManualOverride accepteert willekeurige velden en onbeperkte JSON").
|
||||
/// </summary>
|
||||
public sealed class ManualOverrideCatalog
|
||||
{
|
||||
public const int MaxSerializedValueBytes = 4000;
|
||||
public const int MaxReasonLength = 2000;
|
||||
|
||||
private static readonly IReadOnlyList<string> BooleanValues = ["true", "false"];
|
||||
|
||||
// Matches InfrastructureService's own scalar fields exactly (InfrastructureEntities.cs)
|
||||
// — the only fields ServiceOverrideManager.tsx has ever offered a user, and the
|
||||
// only ones SetOverrideAsync (InfrastructureEndpoints.cs) has ever accepted, since
|
||||
// that endpoint hardcodes InfrastructureObjectKind.Service.
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> ServiceFields =
|
||||
[
|
||||
new("detectedName", "Gedetecteerde naam", ManualOverrideValueKind.Text, 300, null),
|
||||
new("runtimeKind", "Runtimecategorie", ManualOverrideValueKind.Text, 100, null),
|
||||
new("runtimeStatus", "Runtimestatus", ManualOverrideValueKind.Text, 100, null),
|
||||
new("imageReference", "Imagereferentie", ManualOverrideValueKind.Text, 2000, null),
|
||||
new("health", "Healthstatus", ManualOverrideValueKind.Enum, 0, Enum.GetNames<HealthState>()),
|
||||
new("confidence", "Confidence", ManualOverrideValueKind.Enum, 0, Enum.GetNames<FactConfidence>())
|
||||
];
|
||||
|
||||
// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 6): every other
|
||||
// InfrastructureObjectKind was previously left an explicit, deliberately empty
|
||||
// catalog — an honest "no field is overridable yet," not silently unsupported
|
||||
// (see V2_EVIDENCE.md's V2-R1 section). Filled in now that a real need (correcting
|
||||
// an automatically-detected fact for any object kind, not only Service) exists.
|
||||
// Each list matches that entity's own scalar fields exactly
|
||||
// (InfrastructureEntities.cs), the same convention ServiceFields already
|
||||
// established — never an invented field that does not correspond to a real
|
||||
// detected fact. `IInfrastructureRepository.TargetExistsAsync` and
|
||||
// `SetManualOverrideUseCase` were already fully generic across every
|
||||
// `InfrastructureObjectKind`; only the catalog and a reachable endpoint were
|
||||
// missing.
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> HostFields =
|
||||
[
|
||||
new("hostname", "Hostnaam", ManualOverrideValueKind.Text, 300, null),
|
||||
new("operatingSystem", "Besturingssysteem", ManualOverrideValueKind.Text, 200, null),
|
||||
new("kernelVersion", "Kernelversie", ManualOverrideValueKind.Text, 200, null),
|
||||
new("architecture", "Architectuur", ManualOverrideValueKind.Text, 100, null)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> ComponentFields =
|
||||
[
|
||||
new("name", "Naam", ManualOverrideValueKind.Text, 300, null),
|
||||
new("kind", "Soort", ManualOverrideValueKind.Text, 100, null),
|
||||
new("status", "Status", ManualOverrideValueKind.Text, 100, null),
|
||||
new("imageReference", "Imagereferentie", ManualOverrideValueKind.Text, 2000, null)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> EndpointFields =
|
||||
[
|
||||
new("scheme", "Schema", ManualOverrideValueKind.Text, 20, null),
|
||||
new("host", "Host", ManualOverrideValueKind.Text, 300, null),
|
||||
new("url", "URL", ManualOverrideValueKind.Text, 2000, null),
|
||||
new("isPublic", "Publiek bereikbaar", ManualOverrideValueKind.Enum, 0, BooleanValues)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> NetworkFields =
|
||||
[
|
||||
new("name", "Naam", ManualOverrideValueKind.Text, 300, null),
|
||||
new("driver", "Driver", ManualOverrideValueKind.Text, 100, null),
|
||||
new("scope", "Bereik", ManualOverrideValueKind.Text, 100, null),
|
||||
new("internal", "Alleen intern", ManualOverrideValueKind.Enum, 0, BooleanValues)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> VolumeFields =
|
||||
[
|
||||
new("name", "Naam", ManualOverrideValueKind.Text, 300, null),
|
||||
new("mountPath", "Mountpad", ManualOverrideValueKind.Text, 1000, null),
|
||||
new("storageClass", "Opslagklasse", ManualOverrideValueKind.Text, 200, null),
|
||||
new("readOnly", "Alleen-lezen", ManualOverrideValueKind.Enum, 0, BooleanValues)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> DatabaseFields =
|
||||
[
|
||||
new("name", "Naam", ManualOverrideValueKind.Text, 300, null),
|
||||
new("engine", "Database-engine", ManualOverrideValueKind.Text, 100, null),
|
||||
new("version", "Versie", ManualOverrideValueKind.Text, 100, null),
|
||||
new("host", "Host", ManualOverrideValueKind.Text, 300, null)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> DeploymentFields =
|
||||
[
|
||||
new("repositoryUrl", "Repository-URL", ManualOverrideValueKind.Text, 2000, null),
|
||||
new("commit", "Commit", ManualOverrideValueKind.Text, 100, null),
|
||||
new("imageReference", "Imagereferentie", ManualOverrideValueKind.Text, 2000, null),
|
||||
new("imageDigest", "Image-digest", ManualOverrideValueKind.Text, 200, null)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<ManualOverrideFieldDefinition> BackupFields =
|
||||
[
|
||||
new("method", "Methode", ManualOverrideValueKind.Text, 200, null),
|
||||
new("schedule", "Schema", ManualOverrideValueKind.Text, 200, null),
|
||||
new("restoreTested", "Restore getest", ManualOverrideValueKind.Enum, 0, BooleanValues)
|
||||
];
|
||||
|
||||
private static readonly Dictionary<InfrastructureObjectKind, IReadOnlyList<ManualOverrideFieldDefinition>> Catalog =
|
||||
new()
|
||||
{
|
||||
[InfrastructureObjectKind.Service] = ServiceFields,
|
||||
[InfrastructureObjectKind.Host] = HostFields,
|
||||
[InfrastructureObjectKind.Component] = ComponentFields,
|
||||
[InfrastructureObjectKind.Endpoint] = EndpointFields,
|
||||
[InfrastructureObjectKind.Network] = NetworkFields,
|
||||
[InfrastructureObjectKind.Volume] = VolumeFields,
|
||||
[InfrastructureObjectKind.Database] = DatabaseFields,
|
||||
[InfrastructureObjectKind.Deployment] = DeploymentFields,
|
||||
[InfrastructureObjectKind.Backup] = BackupFields
|
||||
};
|
||||
|
||||
public static IReadOnlyList<ManualOverrideFieldDefinition> FieldsFor(InfrastructureObjectKind targetKind) =>
|
||||
Catalog.TryGetValue(targetKind, out var fields) ? fields : [];
|
||||
|
||||
/// <summary>Validates a proposed override against the catalog. Returns an empty
|
||||
/// list when valid.</summary>
|
||||
public static IReadOnlyList<string> Validate(
|
||||
InfrastructureObjectKind targetKind, string fieldName, JsonElement value, string? reason)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(reason) && reason.Length > MaxReasonLength)
|
||||
{
|
||||
errors.Add($"Reden mag maximaal {MaxReasonLength} tekens bevatten.");
|
||||
}
|
||||
|
||||
var definition = FieldsFor(targetKind).FirstOrDefault(field =>
|
||||
string.Equals(field.FieldName, fieldName, StringComparison.Ordinal));
|
||||
if (definition is null)
|
||||
{
|
||||
errors.Add($"Veld '{fieldName}' is niet overrideable voor dit objecttype.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
var rawText = value.GetRawText();
|
||||
if (System.Text.Encoding.UTF8.GetByteCount(rawText) > MaxSerializedValueBytes)
|
||||
{
|
||||
errors.Add($"Overridewaarde is groter dan de toegestane {MaxSerializedValueBytes} bytes.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
errors.Add($"Overridewaarde voor '{definition.DisplayLabel}' moet een tekstwaarde zijn.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
var text = value.GetString() ?? string.Empty;
|
||||
|
||||
switch (definition.ValueKind)
|
||||
{
|
||||
case ManualOverrideValueKind.Text:
|
||||
if (text.Length == 0)
|
||||
{
|
||||
errors.Add($"Overridewaarde voor '{definition.DisplayLabel}' mag niet leeg zijn.");
|
||||
}
|
||||
else if (text.Length > definition.MaxStringLength)
|
||||
{
|
||||
errors.Add($"Overridewaarde voor '{definition.DisplayLabel}' mag maximaal {definition.MaxStringLength} tekens bevatten.");
|
||||
}
|
||||
|
||||
break;
|
||||
case ManualOverrideValueKind.Enum:
|
||||
if (definition.AllowedEnumValues is null
|
||||
|| !definition.AllowedEnumValues.Contains(text, StringComparer.Ordinal))
|
||||
{
|
||||
errors.Add(
|
||||
$"Overridewaarde voor '{definition.DisplayLabel}' moet een van de volgende zijn: {string.Join(", ", definition.AllowedEnumValues ?? [])}.");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Ingestion;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Application.Ingestion;
|
||||
|
||||
/// <summary>Bounded, workspace-scoped queue query for ingestion proposals.</summary>
|
||||
public sealed record IngestionProposalQuery(
|
||||
Guid WorkspaceId,
|
||||
IngestionProposalStatus? Status = null,
|
||||
Guid? Cursor = null,
|
||||
int Limit = 50,
|
||||
int Page = 1,
|
||||
int? PageSize = null,
|
||||
Guid? ProjectId = null,
|
||||
Guid? SubmitterId = null,
|
||||
int? MinAgeDays = null,
|
||||
string? Risk = null,
|
||||
string? DocumentType = null,
|
||||
Guid? PrivateStatusSubmitterId = null);
|
||||
|
||||
/// <summary>A cursor page of ingestion proposals.</summary>
|
||||
public sealed record IngestionProposalPage(
|
||||
IReadOnlyList<IngestionProposal> Items,
|
||||
Guid? NextCursor,
|
||||
int Page = 1,
|
||||
int PageSize = 50,
|
||||
long Total = 0);
|
||||
|
||||
/// <summary>Application persistence boundary for the durable ingestion review dossier.</summary>
|
||||
public interface IIngestionProposalRepository
|
||||
{
|
||||
Task<IngestionProposalPage> ListAsync(IngestionProposalQuery query, CancellationToken cancellationToken);
|
||||
Task<IngestionProposal?> FindAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken);
|
||||
/// <summary>Reads a proposal for endpoint authorization without placing the aggregate
|
||||
/// in the scoped unit-of-work change tracker used by later ETag-checked mutations.</summary>
|
||||
Task<IngestionProposal?> FindReadOnlyAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken) =>
|
||||
FindAsync(workspaceId, proposalId, cancellationToken);
|
||||
Task<IngestionProposal?> FindByIdempotencyKeyAsync(Guid workspaceId, string idempotencyKey, CancellationToken cancellationToken);
|
||||
Task<string?> GetETagAsync(Guid workspaceId, Guid proposalId, CancellationToken cancellationToken);
|
||||
Task<IngestionProposal?> FindForUpdateAsync(Guid workspaceId, Guid proposalId, string expectedETag, CancellationToken cancellationToken);
|
||||
void Add(IngestionProposal proposal);
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user