Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
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";
}
@@ -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;
}
@@ -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_&lt;public-id&gt;_&lt;secret&gt;</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);
}
}
@@ -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;
}
@@ -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&lt;T&gt;()</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&lt;T&gt;</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);
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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_&lt;public-id&gt;_&lt;secret&gt;</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);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
@@ -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);
}
@@ -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));
}
}
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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()));
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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