From 4c4c7ea5386ed6aa11f1d72ec6857f504e6661d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:37:44 +0000 Subject: [PATCH 1/5] Add one-time file links and file text reading A file link row holds the SHA-256 of a 256-bit secret, the user, tenant and API token that created it, its target and its expiry ten minutes out. It stands outside the tenant like ApiTokens and has no history. Using a link deletes it with one DELETE ... RETURNING, so two concurrent uses spend it once; a hosted service removes expired rows every ten minutes. Revoking the token, or deleting its user or tenant, cascades to its links. The text reader returns a file version's text in pages of 50,000 characters: text formats as stored, the PDF text layer through PdfPig and DOCX paragraphs through OpenXml. A file over 30 MB is not read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- .../EvilCase.Api.Contract/Files/FileLimits.cs | 10 + .../Files/FileProblems.cs | 2 + .../Files/FileTextPage.cs | 27 + src/Business/EvilCase.Business/Bootstrap.cs | 5 + .../EvilCase.Business.csproj | 2 + .../Files/Links/ConsumedFileLink.cs | 21 + .../Files/Links/FileLinkCleanup.cs | 32 + .../Files/Links/FileLinkCreateOutcome.cs | 8 + .../Files/Links/FileLinkCreateResult.cs | 13 + .../Files/Links/FileLinkWriter.cs | 103 ++ .../Files/Links/IFileLinkWriter.cs | 19 + .../Files/Text/FileTextExtraction.cs | 70 ++ .../Files/Text/FileTextOutcome.cs | 30 + .../Files/Text/FileTextPaging.cs | 27 + .../Files/Text/FileTextReader.cs | 69 ++ .../Files/Text/FileTextResult.cs | 18 + .../Files/Text/IFileTextReader.cs | 9 + .../EvilCase.Domain/Files/FileLinkKind.cs | 31 + .../20261030200349_FileLinks.Designer.cs | 992 ++++++++++++++++++ .../Migrations/20261030200349_FileLinks.cs | 94 ++ .../ApplicationDbContextModelSnapshot.cs | 78 ++ .../DbContexts/ApplicationDbContext.cs | 21 + src/Data/EvilCase.Data/Entities/FileLink.cs | 47 + src/Directory.Packages.props | 3 + .../Data/DatabaseHistoryTests.cs | 14 +- .../Data/Model/TenantIsolationTests.cs | 4 +- .../Files/FileLinkWriterTests.cs | 181 ++++ .../Files/FileTextExtractionTests.cs | 123 +++ 28 files changed, 2044 insertions(+), 9 deletions(-) create mode 100644 src/Api/EvilCase.Api.Contract/Files/FileTextPage.cs create mode 100644 src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs create mode 100644 src/Business/EvilCase.Business/Files/Links/FileLinkCleanup.cs create mode 100644 src/Business/EvilCase.Business/Files/Links/FileLinkCreateOutcome.cs create mode 100644 src/Business/EvilCase.Business/Files/Links/FileLinkCreateResult.cs create mode 100644 src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs create mode 100644 src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs create mode 100644 src/Business/EvilCase.Business/Files/Text/FileTextExtraction.cs create mode 100644 src/Business/EvilCase.Business/Files/Text/FileTextOutcome.cs create mode 100644 src/Business/EvilCase.Business/Files/Text/FileTextPaging.cs create mode 100644 src/Business/EvilCase.Business/Files/Text/FileTextReader.cs create mode 100644 src/Business/EvilCase.Business/Files/Text/FileTextResult.cs create mode 100644 src/Business/EvilCase.Business/Files/Text/IFileTextReader.cs create mode 100644 src/Business/EvilCase.Domain/Files/FileLinkKind.cs create mode 100644 src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.Designer.cs create mode 100644 src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.cs create mode 100644 src/Data/EvilCase.Data/Entities/FileLink.cs create mode 100644 src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs create mode 100644 src/Tests/EvilCase.Tests/Files/FileTextExtractionTests.cs diff --git a/src/Api/EvilCase.Api.Contract/Files/FileLimits.cs b/src/Api/EvilCase.Api.Contract/Files/FileLimits.cs index 3c41c761c..5886a3e9b 100644 --- a/src/Api/EvilCase.Api.Contract/Files/FileLimits.cs +++ b/src/Api/EvilCase.Api.Contract/Files/FileLimits.cs @@ -31,6 +31,16 @@ public static class FileLimits /// public const long MaxSignedFileBytes = 30 * 1024 * 1024; + /// + /// The largest file whose text is read. + /// + public const long MaxTextFileBytes = 30 * 1024 * 1024; + + /// + /// The characters one page of a file's text carries. + /// + public const int TextPageLength = 50_000; + /// /// The most signers a signed file may carry for them to be listed. /// diff --git a/src/Api/EvilCase.Api.Contract/Files/FileProblems.cs b/src/Api/EvilCase.Api.Contract/Files/FileProblems.cs index 5c1fa3c77..b85f6b433 100644 --- a/src/Api/EvilCase.Api.Contract/Files/FileProblems.cs +++ b/src/Api/EvilCase.Api.Contract/Files/FileProblems.cs @@ -9,4 +9,6 @@ public static class FileProblems public const string VersionNotFound = "File version not found"; public const string LastVersion = "Last file version"; + + public const string LinkNotFound = "File link not found"; } diff --git a/src/Api/EvilCase.Api.Contract/Files/FileTextPage.cs b/src/Api/EvilCase.Api.Contract/Files/FileTextPage.cs new file mode 100644 index 000000000..ffdce59b3 --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/Files/FileTextPage.cs @@ -0,0 +1,27 @@ +namespace EvilBrains.EvilCase.Api.Contract.Files; + +/// +/// One page of the text read from a file version. +/// +public sealed record FileTextPage +{ + public required Guid FileId { get; init; } + + public required string FileName { get; init; } + + public required int Version { get; init; } + + /// + /// 1-based. + /// + public required int Page { get; init; } + + public required int PageCount { get; init; } + + /// + /// The characters of the whole text, every page together. + /// + public required int TotalLength { get; init; } + + public required string Text { get; init; } +} diff --git a/src/Business/EvilCase.Business/Bootstrap.cs b/src/Business/EvilCase.Business/Bootstrap.cs index 1369ddd13..5b257eb6c 100644 --- a/src/Business/EvilCase.Business/Bootstrap.cs +++ b/src/Business/EvilCase.Business/Bootstrap.cs @@ -4,7 +4,9 @@ using EvilBrains.EvilCase.Business.Contacts; using EvilBrains.EvilCase.Business.Export; using EvilBrains.EvilCase.Business.Files; +using EvilBrains.EvilCase.Business.Files.Links; using EvilBrains.EvilCase.Business.Files.Signatures; +using EvilBrains.EvilCase.Business.Files.Text; using EvilBrains.EvilCase.Business.Labels; using EvilBrains.EvilCase.Business.Numbering; using EvilBrains.EvilCase.Business.Search; @@ -43,6 +45,9 @@ public static IServiceCollection AddEvilCaseBusiness(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); services.AddMemoryCache(); services.TryAddSingleton(TimeProvider.System); services.AddSingleton(static _ => SignatureTrust.LoadPostSignum()); diff --git a/src/Business/EvilCase.Business/EvilCase.Business.csproj b/src/Business/EvilCase.Business/EvilCase.Business.csproj index b59331e53..200886519 100644 --- a/src/Business/EvilCase.Business/EvilCase.Business.csproj +++ b/src/Business/EvilCase.Business/EvilCase.Business.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs b/src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs new file mode 100644 index 000000000..3b160181e --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs @@ -0,0 +1,21 @@ +using EvilBrains.EvilCase.Domain.Files; + +namespace EvilBrains.EvilCase.Business.Files.Links; + +/// +/// What a used link names: the request that presented it runs as this user in this tenant. +/// +public sealed record ConsumedFileLink +{ + public required Guid TenantId { get; init; } + + public required Guid UserId { get; init; } + + public required Guid ApiTokenId { get; init; } + + public required FileLinkKind Kind { get; init; } + + public required Guid TargetId { get; init; } + + public int? Version { get; init; } +} diff --git a/src/Business/EvilCase.Business/Files/Links/FileLinkCleanup.cs b/src/Business/EvilCase.Business/Files/Links/FileLinkCleanup.cs new file mode 100644 index 000000000..4093492c1 --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/FileLinkCleanup.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace EvilBrains.EvilCase.Business.Files.Links; + +/// +/// Removes expired links once per link lifetime. An expired link is refused whether or not it is removed yet. +/// +internal sealed class FileLinkCleanup(IServiceScopeFactory scopeFactory, TimeProvider timeProvider, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(FileLinkWriter.Lifetime, timeProvider); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + + var writer = scope.ServiceProvider.GetRequiredService(); + await writer.DeleteExpiredFileLinks(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // A failed round must not stop the host; the next one retries. + logger.LogWarning(exception, "Expired file links were not removed"); + } + } + } +} diff --git a/src/Business/EvilCase.Business/Files/Links/FileLinkCreateOutcome.cs b/src/Business/EvilCase.Business/Files/Links/FileLinkCreateOutcome.cs new file mode 100644 index 000000000..699ce5a08 --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/FileLinkCreateOutcome.cs @@ -0,0 +1,8 @@ +namespace EvilBrains.EvilCase.Business.Files.Links; + +public enum FileLinkCreateOutcome +{ + Created = 0, + + TargetNotFound = 1, +} diff --git a/src/Business/EvilCase.Business/Files/Links/FileLinkCreateResult.cs b/src/Business/EvilCase.Business/Files/Links/FileLinkCreateResult.cs new file mode 100644 index 000000000..137f640eb --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/FileLinkCreateResult.cs @@ -0,0 +1,13 @@ +namespace EvilBrains.EvilCase.Business.Files.Links; + +public sealed record FileLinkCreateResult +{ + public required FileLinkCreateOutcome Outcome { get; init; } + + /// + /// Shown once; only its hash is stored. + /// + public string? Secret { get; init; } + + public DateTime? Expires { get; init; } +} diff --git a/src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs b/src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs new file mode 100644 index 000000000..d0302fa03 --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs @@ -0,0 +1,103 @@ +using System.Buffers.Text; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Data.DbContexts; +using EvilBrains.EvilCase.Data.Entities; +using EvilBrains.EvilCase.Domain.Files; +using EvilBrains.EvilCase.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace EvilBrains.EvilCase.Business.Files.Links; + +/// +/// The secret is 256 random bits stored as SHA-256, like an API token. Nothing here logs it. +/// +internal sealed class FileLinkWriter(IDbSession dbSession, IUserContext userContext, TimeProvider timeProvider, ILogger logger) + : IFileLinkWriter +{ + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); + + private const int SecretSizeInBytes = 32; + + public async Task CreateFileLink(FileLinkKind kind, Guid targetId, int? version, Guid apiTokenId, CancellationToken token) + { + var context = dbSession.Current; + + var targetExists = kind switch + { + FileLinkKind.Download => await context.FileVersions.ReadFileVersion(targetId, version, token) is not null, + FileLinkKind.CaseUpload => await context.Cases.Exists(targetId, token), + FileLinkKind.ActUpload => await context.Acts.Exists(targetId, token), + FileLinkKind.VersionUpload => await context.FileAssets.Exists(targetId, token), + _ => throw new UnreachableException(), + }; + + if (!targetExists) + return new FileLinkCreateResult { Outcome = FileLinkCreateOutcome.TargetNotFound }; + + var secret = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(SecretSizeInBytes)); + var expires = timeProvider.GetUtcNow().UtcDateTime + Lifetime; + + var link = new FileLink + { + SecretHash = Hash(secret), + TenantId = userContext.TenantId, + UserId = userContext.UserId, + ApiTokenId = apiTokenId, + Kind = kind, + TargetId = targetId, + Version = version, + Expires = expires, + }; + + context.FileLinks.Add(link); + await context.SaveChangesAsync(token); + + logger.LogInformation("File link {FileLinkId} ({FileLinkKind}) was created for {TargetId}", link.Id, kind, targetId); + + return new FileLinkCreateResult { Outcome = FileLinkCreateOutcome.Created, Secret = secret, Expires = expires }; + } + + public async Task ConsumeFileLink(string secret, CancellationToken token) + { + var now = timeProvider.GetUtcNow().UtcDateTime; + + // One statement finds and spends the link, so two requests presenting it at once cannot both get it. + var consumed = await dbSession.Current.FileLinks + .FromSql($"""DELETE FROM "FileLinks" WHERE "SecretHash" = {Hash(secret)} RETURNING *""") + .AsNoTracking() + .ToListAsync(token); + + if (consumed.SingleOrDefault() is not { } link || link.Expires <= now) + return null; + + logger.LogInformation("File link {FileLinkId} ({FileLinkKind}) was used for {TargetId}", link.Id, link.Kind, link.TargetId); + + return new ConsumedFileLink + { + TenantId = link.TenantId, + UserId = link.UserId, + ApiTokenId = link.ApiTokenId, + Kind = link.Kind, + TargetId = link.TargetId, + Version = link.Version, + }; + } + + public async Task DeleteExpiredFileLinks(CancellationToken token) + { + var now = timeProvider.GetUtcNow().UtcDateTime; + + await dbSession.Current.FileLinks + .Where(link => link.Expires <= now) + .ExecuteDeleteAsync(token); + } + + private static string Hash(string secret) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))); + } +} diff --git a/src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs b/src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs new file mode 100644 index 000000000..f249c5bae --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs @@ -0,0 +1,19 @@ +using EvilBrains.EvilCase.Domain.Files; + +namespace EvilBrains.EvilCase.Business.Files.Links; + +public interface IFileLinkWriter +{ + /// + /// A link for the caller's user and tenant, valid ; refused where the target, or + /// the version a download names, is unknown in the tenant. + /// + public Task CreateFileLink(FileLinkKind kind, Guid targetId, int? version, Guid apiTokenId, CancellationToken token); + + /// + /// The link the secret names, deleted by the same statement that reads it; null where it is unknown, used or expired. + /// + public Task ConsumeFileLink(string secret, CancellationToken token); + + public Task DeleteExpiredFileLinks(CancellationToken token); +} diff --git a/src/Business/EvilCase.Business/Files/Text/FileTextExtraction.cs b/src/Business/EvilCase.Business/Files/Text/FileTextExtraction.cs new file mode 100644 index 000000000..3bab5f4bb --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Text/FileTextExtraction.cs @@ -0,0 +1,70 @@ +using System.Text; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using UglyToad.PdfPig; +using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor; + +namespace EvilBrains.EvilCase.Business.Files.Text; + +/// +/// The text a file carries by its extension: a text format as stored, the text layer of a PDF, the paragraphs of a DOCX. +/// +internal static class FileTextExtraction +{ + private const string Pdf = ".pdf"; + + private const string Docx = ".docx"; + + private static readonly HashSet PlainTextExtensions = new( + [".txt", ".text", ".csv", ".tsv", ".md", ".json", ".xml", ".html", ".htm", ".log", ".yaml", ".yml"], + StringComparer.OrdinalIgnoreCase); + + public static bool Reads(string fileName) + { + var extension = Path.GetExtension(fileName); + + return PlainTextExtensions.Contains(extension) || IsExtension(extension, Pdf) || IsExtension(extension, Docx); + } + + /// + /// Throws where the content does not parse as its extension says. + /// + public static string Extract(string fileName, Stream content) + { + var extension = Path.GetExtension(fileName); + + if (IsExtension(extension, Pdf)) + return PdfText(content); + + if (IsExtension(extension, Docx)) + return DocxText(content); + + // UTF-8 unless a byte order mark says otherwise. + using var reader = new StreamReader(content, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + return reader.ReadToEnd(); + } + + private static string PdfText(Stream content) + { + using var document = PdfDocument.Open(content); + + return string.Join("\n\n", document.GetPages().Select(static page => ContentOrderTextExtractor.GetText(page))); + } + + private static string DocxText(Stream content) + { + using var document = WordprocessingDocument.Open(content, isEditable: false); + + var body = document.MainDocumentPart?.Document?.Body; + + return body is null + ? "" + : string.Join('\n', body.Descendants().Select(static paragraph => paragraph.InnerText)); + } + + private static bool IsExtension(string extension, string expected) + { + return string.Equals(extension, expected, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Business/EvilCase.Business/Files/Text/FileTextOutcome.cs b/src/Business/EvilCase.Business/Files/Text/FileTextOutcome.cs new file mode 100644 index 000000000..3fd16ca04 --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Text/FileTextOutcome.cs @@ -0,0 +1,30 @@ +namespace EvilBrains.EvilCase.Business.Files.Text; + +public enum FileTextOutcome +{ + Read = 0, + + /// + /// The file or the version is unknown, or its blob is gone. + /// + FileNotFound = 1, + + /// + /// The format is neither a text format, PDF nor DOCX. + /// + UnsupportedFormat = 2, + + TooLarge = 3, + + /// + /// The format carries text, this file none: a scan, or an empty document. + /// + NoText = 4, + + /// + /// The content does not parse as its extension says. + /// + Unreadable = 5, + + PageNotFound = 6, +} diff --git a/src/Business/EvilCase.Business/Files/Text/FileTextPaging.cs b/src/Business/EvilCase.Business/Files/Text/FileTextPaging.cs new file mode 100644 index 000000000..80b0a1745 --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Text/FileTextPaging.cs @@ -0,0 +1,27 @@ +using EvilBrains.EvilCase.Api.Contract.Files; + +namespace EvilBrains.EvilCase.Business.Files.Text; + +/// +/// Pages of characters, numbered from 1; an empty text is one empty page. +/// +internal static class FileTextPaging +{ + public static int PageCount(int length) + { + return length == 0 ? 1 : ((length - 1) / FileLimits.TextPageLength) + 1; + } + + /// + /// Null where the page lies outside the text. + /// + public static string? Page(string text, int page) + { + if (page < 1 || page > PageCount(text.Length)) + return null; + + var start = (page - 1) * FileLimits.TextPageLength; + + return text.Substring(start, Math.Min(FileLimits.TextPageLength, text.Length - start)); + } +} diff --git a/src/Business/EvilCase.Business/Files/Text/FileTextReader.cs b/src/Business/EvilCase.Business/Files/Text/FileTextReader.cs new file mode 100644 index 000000000..bc33e96c2 --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Text/FileTextReader.cs @@ -0,0 +1,69 @@ +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Data.DbContexts; +using EvilBrains.EvilCase.Files; +using Microsoft.Extensions.Logging; + +namespace EvilBrains.EvilCase.Business.Files.Text; + +internal sealed class FileTextReader(IDbSession dbSession, IFileBlobStore blobStore, ILogger logger) : IFileTextReader +{ + public async Task ReadFileText(Guid fileId, int? version, int page, CancellationToken token) + { + var fileVersion = await dbSession.Current.FileVersions.ReadFileVersion(fileId, version, token); + if (fileVersion is null) + return new FileTextResult { Outcome = FileTextOutcome.FileNotFound }; + + var fileName = fileVersion.FileAsset!.FileName; + + if (!FileTextExtraction.Reads(fileName)) + return new FileTextResult { Outcome = FileTextOutcome.UnsupportedFormat }; + + if (fileVersion.SizeBytes > FileLimits.MaxTextFileBytes) + return new FileTextResult { Outcome = FileTextOutcome.TooLarge }; + + await using var content = blobStore.ReadFileBlob(fileVersion.StoragePath); + if (content is null) + return new FileTextResult { Outcome = FileTextOutcome.FileNotFound }; + + // The parsers read synchronously and seek; the blob is read off the disk asynchronously first. + await using var buffer = new MemoryStream(capacity: (int)fileVersion.SizeBytes); + await content.CopyToAsync(buffer, token); + buffer.Position = 0; + + string text; + + try + { + text = FileTextExtraction.Extract(fileName, buffer); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogWarning(exception, "Text of version {FileVersionNumber} of {FileAssetId} could not be read", fileVersion.Number, fileId); + + return new FileTextResult { Outcome = FileTextOutcome.Unreadable }; + } + + if (string.IsNullOrWhiteSpace(text)) + return new FileTextResult { Outcome = FileTextOutcome.NoText }; + + var pageCount = FileTextPaging.PageCount(text.Length); + + if (FileTextPaging.Page(text, page) is not { } pageText) + return new FileTextResult { Outcome = FileTextOutcome.PageNotFound, PageCount = pageCount }; + + return new FileTextResult + { + Outcome = FileTextOutcome.Read, + Page = new FileTextPage + { + FileId = fileId, + FileName = fileName, + Version = fileVersion.Number, + Page = page, + PageCount = pageCount, + TotalLength = text.Length, + Text = pageText, + }, + }; + } +} diff --git a/src/Business/EvilCase.Business/Files/Text/FileTextResult.cs b/src/Business/EvilCase.Business/Files/Text/FileTextResult.cs new file mode 100644 index 000000000..60bddf4aa --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Text/FileTextResult.cs @@ -0,0 +1,18 @@ +using EvilBrains.EvilCase.Api.Contract.Files; + +namespace EvilBrains.EvilCase.Business.Files.Text; + +public sealed record FileTextResult +{ + public required FileTextOutcome Outcome { get; init; } + + /// + /// Set where the outcome is . + /// + public FileTextPage? Page { get; init; } + + /// + /// Set where the outcome is . + /// + public int? PageCount { get; init; } +} diff --git a/src/Business/EvilCase.Business/Files/Text/IFileTextReader.cs b/src/Business/EvilCase.Business/Files/Text/IFileTextReader.cs new file mode 100644 index 000000000..9eb3b78ed --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Text/IFileTextReader.cs @@ -0,0 +1,9 @@ +namespace EvilBrains.EvilCase.Business.Files.Text; + +public interface IFileTextReader +{ + /// + /// One page of the text of a file version; the current version where none is named. + /// + public Task ReadFileText(Guid fileId, int? version, int page, CancellationToken token); +} diff --git a/src/Business/EvilCase.Domain/Files/FileLinkKind.cs b/src/Business/EvilCase.Domain/Files/FileLinkKind.cs new file mode 100644 index 000000000..306ce6587 --- /dev/null +++ b/src/Business/EvilCase.Domain/Files/FileLinkKind.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using EvilBrains.EvilCase.Domain.Json; + +namespace EvilBrains.EvilCase.Domain.Files; + +/// +/// What a one-time file link does and what its target id names. +/// +[JsonConverter(typeof(StrictJsonStringEnumConverter))] +public enum FileLinkKind +{ + /// + /// Downloads a file version; the target is the file. + /// + Download = 0, + + /// + /// Uploads a new file onto a case. + /// + CaseUpload = 1, + + /// + /// Uploads a new file onto an act. + /// + ActUpload = 2, + + /// + /// Uploads a new version of a file. + /// + VersionUpload = 3, +} diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.Designer.cs b/src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.Designer.cs new file mode 100644 index 000000000..a59bb4185 --- /dev/null +++ b/src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.Designer.cs @@ -0,0 +1,992 @@ +// +using System; +using EvilBrains.EvilCase.Data.DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EvilBrains.EvilCase.Data.Migrations.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20261030200349_FileLinks")] + partial class FileLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "unaccent"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Account", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActNumber") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Direction") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("ExternalActNumber") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CaseId"); + + b.HasIndex("ContactId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "ActNumber") + .IsUnique(); + + b.ToTable("Acts", t => + { + t.HasCheckConstraint("CK_Acts_DirectionWithContact", "(\"Direction\" IS NULL) = (\"ContactId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CaseNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExternalCaseNumber") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentCaseId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(18) + .HasColumnType("character varying(18)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("ParentCaseId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "CaseNumber") + .IsUnique(); + + b.ToTable("Cases"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.CaseAncestor", b => + { + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Depth") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("CaseId"); + + b.ToTable((string)null); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Comment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActId"); + + b.HasIndex("CaseId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments", t => + { + t.HasCheckConstraint("CK_Comments_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DataBoxId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActId") + .HasColumnType("uuid"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActId"); + + b.HasIndex("CaseId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("FileAssets", t => + { + t.HasCheckConstraint("CK_FileAssets_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiTokenId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(13) + .HasColumnType("character varying(13)"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiTokenId"); + + b.HasIndex("Expires"); + + b.HasIndex("SecretHash") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("FileLinks"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FileAssetId") + .HasColumnType("uuid"); + + b.Property("MediaType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FileAssetId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "FileAssetId", "Number") + .IsUnique(); + + b.ToTable("FileVersions"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Labels"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.LabelAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActId") + .HasColumnType("uuid"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("LabelId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActId"); + + b.HasIndex("CaseId"); + + b.HasIndex("LabelId"); + + b.HasIndex("TenantId", "LabelId", "ActId") + .IsUnique(); + + b.HasIndex("TenantId", "LabelId", "CaseId") + .IsUnique(); + + b.ToTable("LabelAssignments", t => + { + t.HasCheckConstraint("CK_LabelAssignments_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthSessionId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthSessionId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FailedLoginAttempts") + .HasColumnType("integer"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Acts") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact") + .WithMany("Acts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Case"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact") + .WithMany("Cases") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "ParentCase") + .WithMany("ChildCases") + .HasForeignKey("ParentCaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + + b.Navigation("ParentCase"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Comment", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act") + .WithMany("Comments") + .HasForeignKey("ActId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Comments") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Act"); + + b.Navigation("Case"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act") + .WithMany("Files") + .HasForeignKey("ActId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Files") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Act"); + + b.Navigation("Case"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileLink", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.ApiToken", null) + .WithMany() + .HasForeignKey("ApiTokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.FileAsset", "FileAsset") + .WithMany("Versions") + .HasForeignKey("FileAssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FileAsset"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.LabelAssignment", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act") + .WithMany("Labels") + .HasForeignKey("ActId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Labels") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Label", "Label") + .WithMany("Assignments") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Act"); + + b.Navigation("Case"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.RefreshToken", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Tenant", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.User", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b => + { + b.Navigation("Comments"); + + b.Navigation("Files"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => + { + b.Navigation("Acts"); + + b.Navigation("ChildCases"); + + b.Navigation("Comments"); + + b.Navigation("Files"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b => + { + b.Navigation("Acts"); + + b.Navigation("Cases"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b => + { + b.Navigation("Assignments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.cs b/src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.cs new file mode 100644 index 000000000..03599ed07 --- /dev/null +++ b/src/Data/EvilCase.Data.Migrations/Migrations/20261030200349_FileLinks.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EvilBrains.EvilCase.Data.Migrations.Migrations; + +// Outside the history like ApiTokens: the rows carry the secret hash and die on use. +public partial class FileLinks : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + CreateFileLinks(migrationBuilder); + CreateFileLinkIndexes(migrationBuilder); + + migrationBuilder.Sql( + """CREATE TRIGGER stamp_timestamps BEFORE INSERT OR UPDATE ON "FileLinks" FOR EACH ROW EXECUTE FUNCTION stamp_timestamps();"""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "FileLinks"); + } + + private static void CreateFileLinks(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FileLinks", + columns: static table => new + { + Id = table.Column(type: "uuid", nullable: false), + SecretHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ApiTokenId = table.Column(type: "uuid", nullable: false), + Kind = table.Column(type: "character varying(13)", maxLength: 13, nullable: false), + TargetId = table.Column(type: "uuid", nullable: false), + Version = table.Column(type: "integer", nullable: true), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Updated = table.Column(type: "timestamp with time zone", nullable: true), + Expires = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: static table => + { + table.PrimaryKey("PK_FileLinks", static x => x.Id); + table.ForeignKey( + name: "FK_FileLinks_ApiTokens_ApiTokenId", + column: static x => x.ApiTokenId, + principalTable: "ApiTokens", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FileLinks_Tenants_TenantId", + column: static x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FileLinks_Users_UserId", + column: static x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + private static void CreateFileLinkIndexes(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_FileLinks_ApiTokenId", + table: "FileLinks", + column: "ApiTokenId"); + + migrationBuilder.CreateIndex( + name: "IX_FileLinks_Expires", + table: "FileLinks", + column: "Expires"); + + migrationBuilder.CreateIndex( + name: "IX_FileLinks_SecretHash", + table: "FileLinks", + column: "SecretHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FileLinks_TenantId", + table: "FileLinks", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_FileLinks_UserId", + table: "FileLinks", + column: "UserId"); + } +} diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs index cd8155c82..ec451f9d8 100644 --- a/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs @@ -374,6 +374,63 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiTokenId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(13) + .HasColumnType("character varying(13)"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiTokenId"); + + b.HasIndex("Expires"); + + b.HasIndex("SecretHash") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("FileLinks"); + }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b => { b.Property("Id") @@ -775,6 +832,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Case"); }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileLink", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.ApiToken", null) + .WithMany() + .HasForeignKey("ApiTokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b => { b.HasOne("EvilBrains.EvilCase.Data.Entities.FileAsset", "FileAsset") diff --git a/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs b/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs index 2d7ed462b..499576fff 100644 --- a/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs +++ b/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs @@ -19,6 +19,8 @@ public class ApplicationDbContext(DbContextOptions options public DbSet ApiTokens => this.Set(); + public DbSet FileLinks => this.Set(); + public DbSet Contacts => this.Set(); public DbSet Cases => this.Set(); @@ -179,6 +181,25 @@ private static void ConfigureAccounts(ModelBuilder modelBuilder) .WithMany() .HasForeignKey(static token => token.UserId) .OnDelete(DeleteBehavior.Cascade); + + // A link lives ten minutes; whatever it hangs on takes it along. + modelBuilder.Entity() + .HasOne(typeof(Tenant)) + .WithMany() + .HasForeignKey(nameof(FileLink.TenantId)) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(typeof(User)) + .WithMany() + .HasForeignKey(nameof(FileLink.UserId)) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(typeof(ApiToken)) + .WithMany() + .HasForeignKey(nameof(FileLink.ApiTokenId)) + .OnDelete(DeleteBehavior.Cascade); } private static void ConfigureCases(ModelBuilder modelBuilder) diff --git a/src/Data/EvilCase.Data/Entities/FileLink.cs b/src/Data/EvilCase.Data/Entities/FileLink.cs new file mode 100644 index 000000000..0c0ef710f --- /dev/null +++ b/src/Data/EvilCase.Data/Entities/FileLink.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using EvilBrains.EvilCase.Domain.Files; +using Microsoft.EntityFrameworkCore; + +namespace EvilBrains.EvilCase.Data.Entities; + +/// +/// Outside the tenant like : the anonymous request that presents it finds it before a tenant is +/// known. Its use deletes the row; the revocation of its token deletes it too. +/// +[Index(nameof(SecretHash), IsUnique = true)] +[Index(nameof(Expires))] +public sealed record FileLink : IEntity +{ + [Key] + public Guid Id { get; init; } = Guid.CreateVersion7(); + + /// + /// SHA-256 of the secret, hex. + /// + [MaxLength(64)] + public required string SecretHash { get; init; } + + public required Guid TenantId { get; init; } + + public required Guid UserId { get; init; } + + public required Guid ApiTokenId { get; init; } + + public required FileLinkKind Kind { get; init; } + + /// + /// The case, the act or the file names. + /// + public required Guid TargetId { get; init; } + + /// + /// The version a download names; the current one where it names none. + /// + public int? Version { get; init; } + + public DateTime Created { get; init; } + + public DateTime? Updated { get; init; } + + public required DateTime Expires { get; init; } +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7ff21777d..ea45cf828 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -22,6 +23,7 @@ + @@ -29,6 +31,7 @@ + diff --git a/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs b/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs index 7e433594e..31edf40e6 100644 --- a/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs +++ b/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs @@ -10,8 +10,8 @@ namespace EvilBrains.EvilCase.Tests.Data; /// public class DatabaseHistoryTests { - // Both carry a token hash and change on every use. - private static readonly string[] Unhistorized = ["RefreshTokens", "ApiTokens"]; + // Each carries a secret hash and changes or dies on use. + private static readonly string[] Unhistorized = ["RefreshTokens", "ApiTokens", "FileLinks"]; [Test] public async Task InsertUpdateAndDeleteWriteThreeHistoryRowsInOrder() @@ -69,7 +69,7 @@ await context.Accounts } [Test] - public async Task EveryMappedTableExceptTheTokenTablesCarriesTheRecordTrigger() + public async Task EveryMappedTableExceptTheSecretTablesCarriesTheRecordTrigger() { await using var context = TestDatabase.CreateMigrated(); @@ -94,13 +94,13 @@ FROM pg_trigger t using (Assert.EnterMultipleScope()) { Assert.That(historized, Is.Not.Empty, "the model maps entities carrying history at all, or this test passes vacuously"); - Assert.That(triggered, Is.SupersetOf(historized), "a mapped table other than the token tables has no record_history trigger, so its rows would be written with no history"); - Assert.That(triggered, Has.None.AnyOf(Unhistorized), "the token tables carry the token hash and change on every use, so they stay out of history"); + Assert.That(triggered, Is.SupersetOf(historized), "a mapped table other than the token and link tables has no record_history trigger, so its rows would be written with no history"); + Assert.That(triggered, Has.None.AnyOf(Unhistorized), "the token and link tables carry a secret hash and change or die on use, so they stay out of history"); } } [Test] - public async Task TheTokenTablesHaveNoMirrorTable() + public async Task TheSecretTablesHaveNoMirrorTable() { await using var context = TestDatabase.CreateMigrated(); @@ -114,7 +114,7 @@ FROM pg_class c """) .ToListAsync(); - Assert.That(mirrors, Has.None.AnyOf(Unhistorized), "the token tables carry the token hash and change on every use, so they carry no mirror table"); + Assert.That(mirrors, Has.None.AnyOf(Unhistorized), "the token and link tables carry a secret hash and change or die on use, so they carry no mirror table"); } [Test] diff --git a/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs b/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs index 726dbb6ed..56db3bbcb 100644 --- a/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs +++ b/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs @@ -97,7 +97,7 @@ public void EveryUniqueIndexOfATenantEntityLeadsWithTheTenant() } [Test] - public void TheOnlyRowsOutsideATenantAreAccountsTenantsAndTokens() + public void TheOnlyRowsOutsideATenantAreAccountsTenantsTokensAndFileLinks() { var untenanted = Model.GetEntityTypes() .Where(static entityType => !typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) @@ -106,7 +106,7 @@ public void TheOnlyRowsOutsideATenantAreAccountsTenantsAndTokens() Assert.That( untenanted, - Is.EquivalentTo(["Account", "Tenant", "RefreshToken", "ApiToken", "CaseAncestor"]), + Is.EquivalentTo(["Account", "Tenant", "RefreshToken", "ApiToken", "FileLink", "CaseAncestor"]), "a new entity outside the tenant is a leak until someone says otherwise"); } diff --git a/src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs b/src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs new file mode 100644 index 000000000..3e4180493 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs @@ -0,0 +1,181 @@ +using EvilBrains.EvilCase.Auth; +using EvilBrains.EvilCase.Business.Files.Links; +using EvilBrains.EvilCase.Data.DbContexts; +using EvilBrains.EvilCase.Data.Entities; +using EvilBrains.EvilCase.Domain.ApiTokens; +using EvilBrains.EvilCase.Domain.Files; +using EvilBrains.EvilCase.Tests.Auth; +using EvilBrains.EvilCase.Tests.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EvilBrains.EvilCase.Tests.Files; + +public class FileLinkWriterTests : TenantFixture +{ + private static readonly DateOnly Day = new(2026, 10, 30); + + private readonly TestTimeProvider time = new(new DateTime(2026, 10, 30, 12, 0, 0, DateTimeKind.Utc)); + + private ApiToken apiToken = null!; + + private FileLinkWriter writer = null!; + + [SetUp] + public async Task SetUpWriter() + { + this.apiToken = new ApiToken + { + UserId = this.Tenant.UserId, + Name = "agent", + Scope = ApiTokenScope.ReadWrite, + TokenHash = ApiTokenValue.Hash(ApiTokenValue.Create()), + Expires = this.time.UtcNow.AddDays(90), + }; + + this.Tenant.Context.ApiTokens.Add(this.apiToken); + await this.Tenant.Context.SaveChangesAsync(); + + this.writer = this.WriterOn(this.Tenant.Context); + } + + [Test] + public async Task ALinkIsSpentByItsFirstUse() + { + var @case = await this.Tenant.AddCase(Day); + var created = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, @case.Id, version: null, this.apiToken.Id, CancellationToken.None); + + await using var anonymous = TestDatabase.CreateMigrated(new StubUserContext()); + var anonymousWriter = this.WriterOn(anonymous); + + var first = await anonymousWriter.ConsumeFileLink(created.Secret!, CancellationToken.None); + var second = await anonymousWriter.ConsumeFileLink(created.Secret!, CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(created.Outcome, Is.EqualTo(FileLinkCreateOutcome.Created)); + Assert.That(created.Expires, Is.EqualTo(this.time.UtcNow.AddMinutes(10)), "a link is valid ten minutes"); + Assert.That(first?.TenantId, Is.EqualTo(this.Tenant.UserContext.TenantId), "the use runs in the tenant that created the link, found before any tenant is known"); + Assert.That(first?.UserId, Is.EqualTo(this.Tenant.UserId), "the use runs as the user that created the link"); + Assert.That(first?.Kind, Is.EqualTo(FileLinkKind.CaseUpload)); + Assert.That(first?.TargetId, Is.EqualTo(@case.Id)); + Assert.That(second, Is.Null, "a link is spent by its first use"); + } + } + + [Test] + public async Task TwoUsesAtOnceSpendTheLinkOnce() + { + var @case = await this.Tenant.AddCase(Day); + var created = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, @case.Id, version: null, this.apiToken.Id, CancellationToken.None); + + await using var one = TestDatabase.CreateMigrated(new StubUserContext()); + await using var two = TestDatabase.CreateMigrated(new StubUserContext()); + + var uses = await Task.WhenAll( + this.WriterOn(one).ConsumeFileLink(created.Secret!, CancellationToken.None), + this.WriterOn(two).ConsumeFileLink(created.Secret!, CancellationToken.None)); + + Assert.That(uses.Count(static use => use is not null), Is.EqualTo(1), "one statement finds and deletes the link, so only one of two concurrent uses gets it"); + } + + [Test] + public async Task AnExpiredLinkIsRefused() + { + var @case = await this.Tenant.AddCase(Day); + var created = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, @case.Id, version: null, this.apiToken.Id, CancellationToken.None); + + this.time.Advance(TimeSpan.FromMinutes(10)); + var used = await this.writer.ConsumeFileLink(created.Secret!, CancellationToken.None); + + Assert.That(used, Is.Null, "a link is never usable once expired, removed or not"); + } + + [Test] + public async Task TheCleanupRemovesOnlyExpiredLinks() + { + var @case = await this.Tenant.AddCase(Day); + var older = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, @case.Id, version: null, this.apiToken.Id, CancellationToken.None); + this.time.Advance(TimeSpan.FromMinutes(6)); + var newer = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, @case.Id, version: null, this.apiToken.Id, CancellationToken.None); + this.time.Advance(TimeSpan.FromMinutes(5)); + + await this.writer.DeleteExpiredFileLinks(CancellationToken.None); + + var remaining = await this.Tenant.Context.FileLinks + .AsNoTracking() + .Where(link => link.TargetId == @case.Id) + .Select(static link => link.Expires) + .ToListAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(older.Expires, Is.LessThan(this.time.UtcNow)); + Assert.That(remaining, Is.EqualTo([newer.Expires]), "the cleanup removes the expired link and keeps the live one"); + } + } + + [Test] + public async Task ALinkToACaseOfAnotherTenantIsRefused() + { + await using var other = await TestTenant.Create(); + var otherCase = await other.AddCase(Day); + + var created = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, otherCase.Id, version: null, this.apiToken.Id, CancellationToken.None); + var stored = await this.Tenant.Context.FileLinks.CountAsync(link => link.TargetId == otherCase.Id); + + using (Assert.EnterMultipleScope()) + { + Assert.That(created.Outcome, Is.EqualTo(FileLinkCreateOutcome.TargetNotFound), "a link reaches only a target of the caller's tenant"); + Assert.That(stored, Is.Zero, "a refused link writes no row"); + } + } + + [Test] + public async Task ADownloadLinkToAnUnknownVersionIsRefused() + { + var @case = await this.Tenant.AddCase(Day); + var file = await this.Tenant.AddCaseFile(@case); + + var created = await this.writer.CreateFileLink(FileLinkKind.Download, file.Id, version: 2, this.apiToken.Id, CancellationToken.None); + + Assert.That(created.Outcome, Is.EqualTo(FileLinkCreateOutcome.TargetNotFound)); + } + + [Test] + public async Task TheRowHoldsOnlyTheHashOfTheSecret() + { + var @case = await this.Tenant.AddCase(Day); + var created = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, @case.Id, version: null, this.apiToken.Id, CancellationToken.None); + + var stored = await this.Tenant.Context.FileLinks + .AsNoTracking() + .SingleAsync(link => link.TargetId == @case.Id); + + using (Assert.EnterMultipleScope()) + { + Assert.That(created.Secret, Has.Length.EqualTo(43), "the secret is 256 random bits in base64url"); + Assert.That(stored.SecretHash, Is.Not.EqualTo(created.Secret).And.Length.EqualTo(64), "the row stores the SHA-256 of the secret, never the secret"); + } + } + + [Test] + public async Task RevokingTheTokenTakesItsLinksAlong() + { + var @case = await this.Tenant.AddCase(Day); + var created = await this.writer.CreateFileLink(FileLinkKind.CaseUpload, @case.Id, version: null, this.apiToken.Id, CancellationToken.None); + + await this.Tenant.Context.ApiTokens + .Where(stored => stored.Id == this.apiToken.Id) + .ExecuteDeleteAsync(); + + var used = await this.writer.ConsumeFileLink(created.Secret!, CancellationToken.None); + + Assert.That(used, Is.Null, "a revoked token's links stop working with it"); + } + + private FileLinkWriter WriterOn(ApplicationDbContext context) + { + return new FileLinkWriter(new FixedDbSession(context), this.Tenant.UserContext, this.time, NullLogger.Instance); + } +} diff --git a/src/Tests/EvilCase.Tests/Files/FileTextExtractionTests.cs b/src/Tests/EvilCase.Tests/Files/FileTextExtractionTests.cs new file mode 100644 index 000000000..5f01a3863 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Files/FileTextExtractionTests.cs @@ -0,0 +1,123 @@ +using System.Text; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Business.Files.Text; +using UglyToad.PdfPig.Core; +using UglyToad.PdfPig.Fonts.Standard14Fonts; +using UglyToad.PdfPig.Writer; + +namespace EvilBrains.EvilCase.Tests.Files; + +public class FileTextExtractionTests +{ + [TestCase("zapis.txt", true)] + [TestCase("DATA.CSV", true)] + [TestCase("rozhodnuti.pdf", true)] + [TestCase("podani.docx", true)] + [TestCase("zprava.zfo", false)] + [TestCase("sken.png", false)] + [TestCase("podani.docx.p7m", false)] + [TestCase("bez-pripony", false)] + public void OnlyTextFormatsPdfAndDocxAreRead(string fileName, bool reads) + { + Assert.That(FileTextExtraction.Reads(fileName), Is.EqualTo(reads)); + } + + [Test] + public void ATextFileIsReadAsUtf8WithoutItsByteOrderMark() + { + using var content = new MemoryStream([.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes("Žluťoučký kůň")]); + + Assert.That(FileTextExtraction.Extract("zapis.txt", content), Is.EqualTo("Žluťoučký kůň")); + } + + [Test] + public void APdfGivesTheTextOfItsTextLayerPageByPage() + { + using var content = new MemoryStream(Pdf("First page", "Second page")); + + var text = FileTextExtraction.Extract("rozhodnuti.pdf", content); + + Assert.That(text, Is.EqualTo("First page\n\nSecond page"), "pages follow each other, separated by a blank line"); + } + + [Test] + public void APdfWithoutATextLayerGivesNoText() + { + using var content = new MemoryStream(Pdf()); + + Assert.That(FileTextExtraction.Extract("sken.pdf", content), Is.Empty.Or.Matches(string.IsNullOrWhiteSpace)); + } + + [Test] + public void ADocxGivesItsParagraphsLineByLine() + { + using var content = Docx("První odstavec", "Druhý odstavec"); + + Assert.That(FileTextExtraction.Extract("podani.docx", content), Is.EqualTo("První odstavec\nDruhý odstavec")); + } + + [Test] + public void ContentThatIsNotItsFormatThrows() + { + using var content = new MemoryStream(Encoding.UTF8.GetBytes("not a pdf")); + + Assert.That(() => FileTextExtraction.Extract("rozhodnuti.pdf", content), Throws.Exception, "the reader answers a broken file as unreadable"); + } + + [TestCase(0, 1)] + [TestCase(1, 1)] + [TestCase(FileLimits.TextPageLength, 1)] + [TestCase(FileLimits.TextPageLength + 1, 2)] + [TestCase((3 * FileLimits.TextPageLength) - 1, 3)] + public void APageHoldsFiftyThousandCharacters(int length, int pageCount) + { + Assert.That(FileTextPaging.PageCount(length), Is.EqualTo(pageCount)); + } + + [Test] + public void PagesCutTheTextInOrderWithoutGapOrOverlap() + { + var text = new string('a', FileLimits.TextPageLength) + new string('b', FileLimits.TextPageLength) + "c"; + + using (Assert.EnterMultipleScope()) + { + Assert.That(FileTextPaging.Page(text, 1), Is.EqualTo(new string('a', FileLimits.TextPageLength))); + Assert.That(FileTextPaging.Page(text, 2), Is.EqualTo(new string('b', FileLimits.TextPageLength))); + Assert.That(FileTextPaging.Page(text, 3), Is.EqualTo("c"), "the last page holds the rest"); + Assert.That(FileTextPaging.Page(text, 4), Is.Null, "a page past the last is no page"); + Assert.That(FileTextPaging.Page(text, 0), Is.Null, "pages count from 1"); + } + } + + private static byte[] Pdf(params string[] pages) + { + var builder = new PdfDocumentBuilder(); + var font = builder.AddStandard14Font(Standard14Font.Helvetica); + + if (pages.Length == 0) + builder.AddPage(595, 842); + + foreach (var page in pages) + builder.AddPage(595, 842).AddText(page, 12, new PdfPoint(50, 700), font); + + return builder.Build(); + } + + private static MemoryStream Docx(params string[] paragraphs) + { + var content = new MemoryStream(); + + using (var document = WordprocessingDocument.Create(content, WordprocessingDocumentType.Document)) + { + var main = document.AddMainDocumentPart(); + main.Document = new Document(new Body(paragraphs.Select(static text => new Paragraph(new Run(new Text(text)))).Cast())); + } + + content.Position = 0; + + return content; + } +} From b17d167520fadb619f18e2857d133b42c5c5dd64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:38:00 +0000 Subject: [PATCH 2/5] Serve an MCP server on /mcp for AI agents /mcp speaks stateless Streamable HTTP through the official SDK. A policy re-authenticates with the API token scheme alone, so the browser session and an access token get 401, and a foreign Origin gets 403 as a cross-site write does. Every MCP message is a POST, so the read-only boundary lets /mcp through; filters list only the read tools to a read-only token and refuse it the others. The tools call the business services in process with the contract DTOs, paging and filters of REST, and answer a validation, not-found or conflict outcome with its Problem Details title and detail as a tool error. File bytes travel only through one-time links on the anonymous /api/file-links: a PUT of raw bytes under the upload rules, or a GET sent with Cache-Control: no-store. The secret travels in the query string because the request log records only the path; hosting diagnostics and the SDK are pinned to Warning, below which they log the query string and tool results. The download link tool is a read tool, so a read-only token can fetch a file. Production sets AllowedHosts to its public host, since the link URL takes the scheme and host of the /mcp request. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- README.md | 10 + deploy/.env.example | 4 + deploy/README.md | 4 +- deploy/docker-compose.yml | 2 + docs/sdd/README.md | 4 +- docs/sdd/sdd-001-architektura.md | 4 +- docs/sdd/sdd-002-logovani-a-observabilita.md | 7 +- docs/sdd/sdd-005-api-konvence.md | 17 +- docs/sdd/sdd-006-tenance-a-ucty.md | 13 +- docs/sdd/sdd-018-prace-s-databazi.md | 3 +- docs/sdd/sdd-023-mcp-server.md | 86 +++++ .../EvilCase.Api.Contract/Files/FileLink.cs | 18 + .../Files/FileLinkRoute.cs | 16 + src/Api/EvilCase.Api.Contract/Mcp/McpRoute.cs | 9 + src/Api/EvilCase.Api/Bootstrap.cs | 31 ++ .../Controllers/FileContentAnswer.cs | 25 ++ .../Controllers/FileLinksController.cs | 126 +++++++ .../Controllers/FileTransferController.cs | 42 +-- .../Controllers/FileUploadAnswer.cs | 40 +++ src/Api/EvilCase.Api/EvilCase.Api.csproj | 1 + src/Api/EvilCase.Api/Mcp/ActTools.cs | 111 +++++++ src/Api/EvilCase.Api/Mcp/CaseTools.cs | 118 +++++++ src/Api/EvilCase.Api/Mcp/CommentTools.cs | 124 +++++++ src/Api/EvilCase.Api/Mcp/ContactTools.cs | 75 +++++ src/Api/EvilCase.Api/Mcp/FileLinkTools.cs | 114 +++++++ src/Api/EvilCase.Api/Mcp/FileTools.cs | 167 ++++++++++ src/Api/EvilCase.Api/Mcp/LabelTools.cs | 67 ++++ src/Api/EvilCase.Api/Mcp/McpProblem.cs | 30 ++ src/Api/EvilCase.Api/Mcp/McpScopeFilters.cs | 53 +++ src/Api/EvilCase.Api/Mcp/SearchTools.cs | 21 ++ src/Api/EvilCase.Api/Mcp/SignatureTools.cs | 49 +++ .../ApiTokenBoundaryMiddleware.cs | 7 +- src/Common/EvilCase.Auth/ApiTokenPolicy.cs | 9 + src/Common/EvilCase.Auth/Bootstrap.cs | 8 +- src/EvilCase.Host/CrossSiteWriteMiddleware.cs | 15 +- src/EvilCase.Host/Program.cs | 11 +- .../Hosting/FileLinkEndpointTests.cs | 217 ++++++++++++ .../Hosting/McpEndpointTests.cs | 309 ++++++++++++++++++ .../EvilCase.Tests/Mcp/McpToolTenancyTests.cs | 67 ++++ 39 files changed, 1970 insertions(+), 64 deletions(-) create mode 100644 docs/sdd/sdd-023-mcp-server.md create mode 100644 src/Api/EvilCase.Api.Contract/Files/FileLink.cs create mode 100644 src/Api/EvilCase.Api.Contract/Files/FileLinkRoute.cs create mode 100644 src/Api/EvilCase.Api.Contract/Mcp/McpRoute.cs create mode 100644 src/Api/EvilCase.Api/Controllers/FileContentAnswer.cs create mode 100644 src/Api/EvilCase.Api/Controllers/FileLinksController.cs create mode 100644 src/Api/EvilCase.Api/Controllers/FileUploadAnswer.cs create mode 100644 src/Api/EvilCase.Api/Mcp/ActTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/CaseTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/CommentTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/ContactTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/FileLinkTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/FileTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/LabelTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/McpProblem.cs create mode 100644 src/Api/EvilCase.Api/Mcp/McpScopeFilters.cs create mode 100644 src/Api/EvilCase.Api/Mcp/SearchTools.cs create mode 100644 src/Api/EvilCase.Api/Mcp/SignatureTools.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenPolicy.cs create mode 100644 src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs create mode 100644 src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs create mode 100644 src/Tests/EvilCase.Tests/Mcp/McpToolTenancyTests.cs diff --git a/README.md b/README.md index a83e0b0ca..082a30569 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,13 @@ curl -H "Authorization: Bearer evc_…" "https://localhost:5000/api/cases?take=2 ``` The token acts as its user, tenant included. A read-only token gets 403 on every write, and an unknown, revoked or expired token gets 401. `/api/auth/**` and token management accept the browser session only. `/openapi/v1.json` describes every endpoint and the Bearer scheme, and is anonymous in every environment. + +## AI agents over MCP + +`/mcp` is an MCP server (Streamable HTTP) for AI agents, with a tool for every REST resource except token management and the file archive. It accepts an API token only; the browser session and an access token get 401. Connect Claude Code: + +``` +claude mcp add --transport http evilcase https:///mcp --header "Authorization: Bearer " +``` + +A read-only token lists and calls only the read tools. File bytes never travel through a tool: `create_file_upload_link` and `create_file_download_link` return a one-time URL, valid 10 minutes, with a curl example; the upload is a PUT of the raw bytes. `read_file_text` returns the text of a text file, PDF or DOCX in pages of 50,000 characters. diff --git a/deploy/.env.example b/deploy/.env.example index 6ee22c722..0d4970060 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -8,6 +8,10 @@ EVILCASE_TAG=latest # forwarded headers of the reverse proxy in front, so nothing else may reach it. EVILCASE_PORT=8080 +# Public host name the reverse proxy serves, without scheme or port. The application answers to it +# alone (and to localhost for the health check), and builds the MCP file links from it. +EVILCASE_HOST=evilcase.example.com + EVILCASE_CONNECTION_STRING=Host=db.example.com;Port=5432;Database=evilcase;Username=evilcase;Password= # Signing key for the JWT tokens, at least 32 characters. Startup fails on an empty or shorter one. diff --git a/deploy/README.md b/deploy/README.md index 6067ee3eb..7897f6ea5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -36,6 +36,8 @@ Two keys under `EvilBrains:EvilCase:Hosting` adapt the pipeline to what sits in - `BehindReverseProxy` (default `false`) calls `UseForwardedHeaders` first, with `KnownIPNetworks` and `KnownProxies` cleared and `ForwardLimit = 1`. The single trusted hop is the whole defence: a deployment that turns it on must not be reachable except through that proxy. - `HttpsRedirection` (default `true`) turns `UseHttpsRedirection` off where something in front already redirects. `/health/*` is excluded from redirection either way. +The one-time file links the MCP server hands out are absolute URLs built from the scheme and host of the `/mcp` request. `AllowedHosts` (top-level key) lists the hosts the application answers to; production sets it to the public host plus `localhost`, which the container health check calls. Behind a proxy, `BehindReverseProxy` supplies the scheme from `X-Forwarded-Proto`; the host is the `Host` header, so the proxy forwards the original one. + ## Outbound network Verifying the seal of an ISDS data message asks PostSignum for the revocation status of its @@ -49,7 +51,7 @@ certificate: OCSP and CRL over plain HTTP at the URLs the PostSignum certificate The database is not part of the stack — `EVILCASE_CONNECTION_STRING` points at an existing PostgreSQL, version 18 or later. The schema is migrated on startup unless `EvilBrains__EvilCase__Database__MigrateOnStartup=false` is added to the service environment; where more than one instance starts at once, roll it out separately from the idempotent `database.sql` artifact CI publishes. The role in the connection string must be able to `CREATE EXTENSION`, for `unaccent` and `pg_trgm`, and `CREATE SCHEMA`, for the `history` schema. -The service is published over plain HTTP for a reverse proxy that terminates TLS, so it sets `BehindReverseProxy=true` and `HttpsRedirection=false`; the port is published on `127.0.0.1` only (`EVILCASE_PORT` picks the host port), keeping the service unreachable except through the proxy. +The service is published over plain HTTP for a reverse proxy that terminates TLS, so it sets `BehindReverseProxy=true` and `HttpsRedirection=false`. `EVILCASE_HOST` is the public host name; the stack sets `AllowedHosts` to it and `localhost`. The port is published on `127.0.0.1` only (`EVILCASE_PORT` picks the host port), keeping the service unreachable except through the proxy. Seq is driven by `EVILCASE_SEQ_URL` alone — an empty one logs to the console only. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e53e01b96..8a656c433 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,6 +16,8 @@ services: # unset means no account is created, and an existing user means none is created either way. EvilBrains__EvilCase__Auth__Seed__Email: ${EVILCASE_SEED_EMAIL:-} EvilBrains__EvilCase__Auth__Seed__Password: ${EVILCASE_SEED_PASSWORD:-} + # The public host, which the file links of the MCP server name, and the one the health check calls. + AllowedHosts: "${EVILCASE_HOST:?};localhost" EvilBrains__EvilCase__Hosting__BehindReverseProxy: "true" # The proxy owns the redirect. Kestrel serves plain HTTP here, so the middleware could not # resolve a target port anyway and would only log a warning per request. diff --git a/docs/sdd/README.md b/docs/sdd/README.md index b6a1ebc1e..a60af1049 100644 --- a/docs/sdd/README.md +++ b/docs/sdd/README.md @@ -14,7 +14,8 @@ SDD (software design document) závazně popisuje jednu oblast návrhu EvilCase. ## Pořadí čtení Architektura 001 jako první. Průřezová 002–005 a 018 platí pro každou změnu ve své oblasti; -základ 006–008 (tenance, model, číslování), agendy 009–013 a 019, aplikace 015–017, 020–022. +základ 006–008 (tenance, model, číslování), agendy 009–013 a 019, aplikace 015–017, 020–022, +MCP server 023. ## Mapa SDD ↔ milník @@ -41,3 +42,4 @@ základ 006–008 (tenance, model, číslování), agendy 009–013 a 019, aplik | [020](sdd-020-vzhled.md) | Vzhled | M9 | | [021](sdd-021-vyhledavani.md) | Vyhledávání | M9 | | [022](sdd-022-export.md) | Export | — | +| [023](sdd-023-mcp-server.md) | MCP server | průřez | diff --git a/docs/sdd/sdd-001-architektura.md b/docs/sdd/sdd-001-architektura.md index 5fe3ea248..9e68cb91d 100644 --- a/docs/sdd/sdd-001-architektura.md +++ b/docs/sdd/sdd-001-architektura.md @@ -16,8 +16,8 @@ SDD-012. `.claude/rules/` nese z tohoto návrhu invarianty pro práci s kódem. ### Běhový model -Jedna aplikace, jeden proces. `EvilCase.Host` obsluhuje `/api/**` jako REST API a na každé -jiné cestě vrací `index.html` frontendu. Frontend je Blazor WebAssembly: běží v prohlížeči +Jedna aplikace, jeden proces. `EvilCase.Host` obsluhuje `/api/**` jako REST API, `/mcp` jako +MCP server pro AI agenty (SDD-023) a na každé jiné cestě vrací `index.html` frontendu. Frontend je Blazor WebAssembly: běží v prohlížeči a se serverem mluví jen přes API, typovaným klientem (SDD-005). Stav drží PostgreSQL; bajty souborů leží na souborovém systému, databáze nese jejich diff --git a/docs/sdd/sdd-002-logovani-a-observabilita.md b/docs/sdd/sdd-002-logovani-a-observabilita.md index 378c30084..b4f7fd41b 100644 --- a/docs/sdd/sdd-002-logovani-a-observabilita.md +++ b/docs/sdd/sdd-002-logovani-a-observabilita.md @@ -14,8 +14,8 @@ Co se loguje a co nikdy. ### Pipeline Logování jde přes `EvilBrains.Logging.*` na serveru i ve WebAssembly. Seq zapíná URL -z prostředí; bez ní se loguje jen do konzole. Logují se požadavky na `/api/**` kromě úspěšného -uploadu klientských logů — ten by se sám sebou zalogoval do dalšího uploadu. Mimo `/api/**` se +z prostředí; bez ní se loguje jen do konzole. Logují se požadavky na `/api/**` a `/mcp` kromě +úspěšného uploadu klientských logů — ten by se sám sebou zalogoval do dalšího uploadu. Mimo ně se nikde neloguje nic, health checks včetně; výjimka je výjimka nebo odpověď 5xx, ta se loguje vždy. ### Co loguje každá agenda @@ -39,7 +39,8 @@ entit, v každé agendě stejně. Výjimkou je seed vzorových dat, který loguj ### Nikdy -Obsah souborů, těla komentářů, obsah reálných spisů. Log nese identifikátory, ne obsah. +Obsah souborů, těla komentářů, obsah reálných spisů, adresa jednorázového odkazu na soubor +(SDD-023). Log nese identifikátory, ne obsah. ## Rozhodnutí diff --git a/docs/sdd/sdd-005-api-konvence.md b/docs/sdd/sdd-005-api-konvence.md index 86f85fd7d..3c42b5807 100644 --- a/docs/sdd/sdd-005-api-konvence.md +++ b/docs/sdd/sdd-005-api-konvence.md @@ -3,7 +3,7 @@ - **Stav:** platí - **Milníky:** průřez - **Související SDD:** [004](sdd-004-validace-a-chyby.md), [006](sdd-006-tenance-a-ucty.md), - [016](sdd-016-navigace-a-vzhled.md) + [016](sdd-016-navigace-a-vzhled.md), [023](sdd-023-mcp-server.md) ## Rozsah @@ -15,11 +15,12 @@ Tvary API a klienta. - Kontrolery jsou jediný zdroj pravdy; klient se generuje z jejich zdrojů; DTOs žijí v `EvilCase.Api.Contract`. -- Jeden proces, API pod `/api/**`, same-origin, bez CORS. Neznámá cesta pod `/api` je 404 - jako Problem Details, nikdy `index.html` frontendu. +- Jeden proces, API pod `/api/**`, MCP server na `/mcp` (SDD-023), same-origin, bez CORS. + Neznámá cesta pod `/api` je 404 jako Problem Details, nikdy `index.html` frontendu. - Business služba vrací kontraktní DTO; žádná druhá sada modelů. - Endpoint bez výslovné výjimky vyžaduje přihlášení. Anonymní jsou přihlášení, obnova tokenu, - odhlášení, health checks, upload klientských logů, 404 pod `/api`, v každém prostředí + odhlášení, health checks, upload klientských logů, upload a download jednorázovým odkazem + `/api/file-links` (přihlášením je odkaz sám, SDD-023), 404 pod `/api`, v každém prostředí `/openapi/v1.json` a v Development `/scalar`. - Přihlášení nese cookie, nebo API token v hlavičce `Authorization: Bearer` (SDD-006). OpenAPI dokument deklaruje schéma Bearer. @@ -57,13 +58,19 @@ s `Retry-After`. | Hledání | `/api/search?q=`, viz [SDD-021](sdd-021-vyhledavani.md) | | Štítky | `/api/labels`, `/api/labels/{id}`; přidělení na vlastníku `…/labels` | | API tokeny | `/api/api-tokens`, zrušení `/api/api-tokens/{id}`; jen přihlášení v prohlížeči (SDD-006) | +| Jednorázové odkazy | `/api/file-links?secret=`: GET stáhne obsah, PUT s `&fileName=` nahraje surové bajty; bez přihlášení (SDD-023) | +| MCP | `/mcp`, nástroje nad všemi zdroji výše kromě API tokenů a archivu souborů; jen API token (SDD-023) | - Id v routách je `Guid`. - Tenanta i uživatele dodává kontext požadavku; endpoint ani dotaz nikdy neberou id tenanta parametrem (SDD-006). - Upload a download jdou přes ručně psaný klient, ne přes generovaný: generátor neumí multipart formulář ani proud bajtů. Upload nové verze také. Výpis, přejmenování a smazání souboru - a výpis a smazání verzí jdou přes generovaný. + a výpis a smazání verzí jdou přes generovaný. Jednorázové odkazy generovaný klient nemá; vydávají + je nástroje MCP. +- Upload jednorázovým odkazem drží pravidla uploadu (SDD-012) a odpovídá jako upload na vlastníku. + Download jednorázovým odkazem odpovídá jako download obsahu a nese `Cache-Control: no-store`. + Neznámý, použitý nebo prošlý odkaz je 404. ## Rozhodnutí diff --git a/docs/sdd/sdd-006-tenance-a-ucty.md b/docs/sdd/sdd-006-tenance-a-ucty.md index 66271abd9..6eab8cda5 100644 --- a/docs/sdd/sdd-006-tenance-a-ucty.md +++ b/docs/sdd/sdd-006-tenance-a-ucty.md @@ -4,7 +4,7 @@ - **Milníky:** M2 - **Související SDD:** [002](sdd-002-logovani-a-observabilita.md), [003](sdd-003-testovani.md), [004](sdd-004-validace-a-chyby.md), [005](sdd-005-api-konvence.md), - [007](sdd-007-domenovy-model.md), [019](sdd-019-stitky.md) + [007](sdd-007-domenovy-model.md), [019](sdd-019-stitky.md), [023](sdd-023-mcp-server.md) ## Rozsah @@ -20,7 +20,8 @@ popisuje SDD-007. - **User** — tenantová entita, patří právě jednomu tenantu: e-mail, hash hesla, role, lockout a `TenantId`. -Mimo tenant stojí jen Account, Tenant, refresh token a API token; každá jiná entita nese +Mimo tenant stojí jen Account, Tenant, refresh token, API token a jednorázový odkaz na soubor +(SDD-023); každá jiná entita nese `TenantId`. Vlastníka `UserId` nese každá kromě kontaktu; kontakt patří tenantu (SDD-011). Obojí plní zápis, ne volající. Tenanta ani uživatele nelze smazat, dokud drží řádky. @@ -39,7 +40,7 @@ Data nesmí utéct mezi tenanty; únik je kritická chyba. - Čtení nikdy nevidí řádek cizího tenantu; tenanta i uživatele dodává kontext požadavku. Výjimka je hledání uživatele, které tenanta ještě znát nemůže — přihlášení, obnova tokenu, - ověření API tokenu a oba seedy. + ověření API tokenu, použití jednorázového odkazu a oba seedy. - Access token nese tenant claim i subject claim. - Zápis doplní `TenantId` a `UserId` nové entity z kontextu a odmítne řádek, který jmenuje cizího tenanta nebo cizího uživatele. @@ -49,7 +50,7 @@ Data nesmí utéct mezi tenanty; únik je kritická chyba. přes celé nasazení a ukládá se oříznutý a malými písmeny, takže přihlášení nerozlišuje velikost písmen. - Konvenční test hlídá, že žádná tenantová entita filtr nepostrádá a že mimo tenant stojí jen - Account, Tenant, refresh token a API token (SDD-003). + Account, Tenant, refresh token, API token a jednorázový odkaz na soubor (SDD-003). ### Autentizace @@ -81,7 +82,9 @@ jeho hash. Posílá se v hlavičce `Authorization: Bearer` a schéma vybere jeho v hlavičce vyhraje nad cookie přihlášení. Neznámý, zrušený nebo prošlý token odpoví 401. Zrušení token smaže a platí od dalšího požadavku, nic token necachuje. -- Token jen pro čtení odpoví 403 na každý zápis: POST, PUT, PATCH, DELETE. +- Token jen pro čtení odpoví 403 na každý zápis: POST, PUT, PATCH, DELETE. Na `/mcp` vidí + a volá jen nástroje pro čtení (SDD-023). +- `/mcp` přijme jen API token; cookie ani access token tam neplatí (SDD-023). - Na `/api/auth/**` a na správu tokenů odpoví token 403; obojí zůstává přihlášení v prohlížeči. - Uzamčení účtu token nezastaví, stejně jako živé přihlášení. - Požadavky s tokenem limit požadavků nemají (SDD-005). diff --git a/docs/sdd/sdd-018-prace-s-databazi.md b/docs/sdd/sdd-018-prace-s-databazi.md index ee7c978bc..940491440 100644 --- a/docs/sdd/sdd-018-prace-s-databazi.md +++ b/docs/sdd/sdd-018-prace-s-databazi.md @@ -43,7 +43,8 @@ a refresh tokeny filtr nemají a jmenují řádku jejím id. - Každý zápis do historizované tabulky — `Accounts`, `Tenants`, `Users`, `Contacts`, `Cases`, `Acts`, `FileAssets`, `FileVersions`, `Comments` — zůstává navždy jako řádek se svým operátorem a časem, i po smazání původního řádku. `RefreshTokens` historii nemá: rotují při - každém refreshi a nesou hash tokenu. + každém refreshi a nesou hash tokenu. `FileLinks` také ne: žijí deset minut a nesou hash + odkazu (SDD-023). - Historický řádek je jen k čtení, žádný zápis ho nezmění ani nesmaže. - Operátor je ten, kdo byl přihlášený v okamžiku zápisu, ne vlastník řádku. diff --git a/docs/sdd/sdd-023-mcp-server.md b/docs/sdd/sdd-023-mcp-server.md new file mode 100644 index 000000000..b6f9b3af6 --- /dev/null +++ b/docs/sdd/sdd-023-mcp-server.md @@ -0,0 +1,86 @@ +# SDD-023 — MCP server + +- **Stav:** platí +- **Milníky:** průřez +- **Související SDD:** [002](sdd-002-logovani-a-observabilita.md), [004](sdd-004-validace-a-chyby.md), + [005](sdd-005-api-konvence.md), [006](sdd-006-tenance-a-ucty.md), [012](sdd-012-soubory.md) + +## Rozsah + +MCP server pro AI agenty: přihlášení, nástroje, chyby, jednorázové odkazy na soubory a čtení +textu souboru. Tvary REST API drží SDD-005, API tokeny SDD-006. + +## Popis + +### Server + +- `/mcp` mluví Streamable HTTP bez stavu: každá zpráva je samostatný POST, server nedrží relaci. +- Přihlašuje jen API token v hlavičce `Authorization: Bearer`. Bez tokenu, s cookie přihlášení + nebo s access tokenem odpoví 401; neznámý, zrušený nebo prošlý token také. +- Požadavek s hlavičkou `Origin` jiného originu než aplikace odpoví 403, i s platným tokenem. + Požadavek bez `Origin` projde. +- Nástroj jedná jako uživatel tokenu v jeho tenantu, stejně jako REST požadavek s tokenem. + +### Nástroje + +- Nástroje pokrývají každý zdroj REST API kromě API tokenů a archivu souborů (SDD-005): spisy, + úkony, kontakty, štítky a jejich přidělení, komentáře, soubory a verze, podpisy a hledání. + Upload a download obsahu jdou jen jednorázovým odkazem. +- Názvy nástrojů jsou snake_case (`list_cases`, `delete_label`), každý nástroj i parametr nese + anglický popis. +- Nástroj pro čtení nese `readOnlyHint`, nástroj mazání `destructiveHint`. `openWorldHint` nesou + jen nástroje, které ověřují podpis online u autority. +- Nástroje berou a vracejí kontraktní DTOs REST API; výpis bere stejnou stránku (`Take` 1 až 100) + a stejná zúžení. +- Token jen pro čtení vidí ve výpisu nástrojů jen nástroje pro čtení. Volání jiného nástroje + vrátí chybu nástroje a nic nezmění. +- Chyba validace, nenalezený záznam, konflikt i odepření vrátí chybu nástroje s titulkem + a detailem Problem Details, jaké vrací REST (SDD-004). Text výjimky se nevrací nikdy. +- Obsah souboru nejde do argumentu nástroje ani z výsledku, kromě textu z `read_file_text`. + +### Jednorázové odkazy + +- `create_file_upload_link` vydá odkaz na upload nového souboru do spisu nebo úkonu, nebo nové + verze souboru, pod jménem z argumentu. `create_file_download_link` vydá odkaz na stažení + verze souboru, bez čísla aktuální; je to nástroj pro čtení. +- Výsledek nese metodu, absolutní adresu, okamžik vypršení a příklad volání curl. +- Adresa je `/api/file-links` se schématem a hostem požadavku na `/mcp`. Tajemství je 256 + náhodných bitů v base64url v parametru `secret`, jméno uploadu v parametru `fileName`. +- Odkaz platí 10 minut a spotřebuje ho první použití, i neúspěšné; ze souběžných použití projde + jen jedno. Uloží se jen + SHA-256 tajemství s uživatelem, tenantem, tokenem, cílem a vypršením. Prošlé odkazy se mažou + každých 10 minut. +- Použití běží bez přihlášení jako uživatel odkazu v jeho tenantu. Cíl musí patřit tenantu při + vydání i při použití. +- Zrušení tokenu, smazání uživatele nebo tenantu zruší jeho odkazy. +- Upload je PUT surových bajtů. Drží pravidla uploadu (SDD-012): 100 MB, jméno, `MediaType` + z `Content-Type`, 200 beze změny, 201 se souborem; neplatné jméno odmítne dřív, než odkaz + spotřebuje. +- Download je GET a odpovídá jako download obsahu s `Cache-Control: no-store`. +- Neznámý, použitý, prošlý nebo jinému účelu vydaný odkaz je 404. +- Adresa odkazu se nedostane do logu (SDD-002). + +### Text souboru + +- `read_file_text` vrátí text verze souboru po stránkách po 50 000 znacích, stránky od 1, + s celkovou délkou textu a počtem stránek. +- Text se čte z textových formátů (txt, text, csv, tsv, log, md, json, xml, html, htm, yaml, + yml) jako UTF-8 bez BOM, z textové vrstvy PDF a z odstavců DOCX. Jiný formát vrátí chybu + nástroje. +- Soubor nad 30 MB se nečte. Soubor bez textu, třeba sken, to řekne chybou nástroje. + +## Rozhodnutí + +- Přihlášení: OAuth / API token. Platí API token. +- Relace serveru: se stavem / bez stavu. Platí bez stavu. +- Obsah souboru: v argumentech a výsledcích nástrojů / jednorázovým odkazem. Platí odkaz. +- Tajemství odkazu: v cestě / v query stringu. Platí query string, protože log požadavku nese + jen cestu. +- Odkaz na stažení: nástroj zápisu / nástroj pro čtení. Platí nástroj pro čtení; token jen pro + čtení soubor stáhne. +- Text datové zprávy, e-mailu a podepsaného souboru: číst / nečíst. Platí nečíst. + +## Dopady + +Anonymní endpointy a zdroje drží SDD-005, model mimo tenant SDD-006, log SDD-002, historizaci +SDD-018. diff --git a/src/Api/EvilCase.Api.Contract/Files/FileLink.cs b/src/Api/EvilCase.Api.Contract/Files/FileLink.cs new file mode 100644 index 000000000..c5a5579b0 --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/Files/FileLink.cs @@ -0,0 +1,18 @@ +namespace EvilBrains.EvilCase.Api.Contract.Files; + +/// +/// A one-time link that uploads or downloads the bytes of a file outside the MCP connection. +/// +public sealed record FileLink +{ + public required string Method { get; init; } + + /// + /// Absolute; it carries the secret, so it is a credential until used or expired. + /// + public required string Url { get; init; } + + public required DateTime Expires { get; init; } + + public required string Curl { get; init; } +} diff --git a/src/Api/EvilCase.Api.Contract/Files/FileLinkRoute.cs b/src/Api/EvilCase.Api.Contract/Files/FileLinkRoute.cs new file mode 100644 index 000000000..fe547255b --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/Files/FileLinkRoute.cs @@ -0,0 +1,16 @@ +namespace EvilBrains.EvilCase.Api.Contract.Files; + +/// +/// The secret travels in the query string: the request path reaches every log event of the request, the query string +/// none. +/// +public static class FileLinkRoute +{ + public const string Template = "api/file-links"; + + public const string Path = "/" + Template; + + public const string SecretParameter = "secret"; + + public const string FileNameParameter = "fileName"; +} diff --git a/src/Api/EvilCase.Api.Contract/Mcp/McpRoute.cs b/src/Api/EvilCase.Api.Contract/Mcp/McpRoute.cs new file mode 100644 index 000000000..9fdd3c8d3 --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/Mcp/McpRoute.cs @@ -0,0 +1,9 @@ +namespace EvilBrains.EvilCase.Api.Contract.Mcp; + +/// +/// Shared with the API token boundary and the cross-site check, which treat the MCP endpoint apart from the REST API. +/// +public static class McpRoute +{ + public const string Path = "/mcp"; +} diff --git a/src/Api/EvilCase.Api/Bootstrap.cs b/src/Api/EvilCase.Api/Bootstrap.cs index eff2a580c..ec6014a81 100644 --- a/src/Api/EvilCase.Api/Bootstrap.cs +++ b/src/Api/EvilCase.Api/Bootstrap.cs @@ -1,6 +1,9 @@ using EvilBrains.EvilCase.Api.Auth; using EvilBrains.EvilCase.Api.Binding; +using EvilBrains.EvilCase.Api.Contract.Mcp; using EvilBrains.EvilCase.Api.HealthChecks; +using EvilBrains.EvilCase.Api.Mcp; +using EvilBrains.EvilCase.Auth; using EvilBrains.EvilCase.Business; using EvilBrains.EvilCase.Domain.Users; using EvilBrains.Logging.AspNetCore; @@ -12,6 +15,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.OpenApi; +using ModelContextProtocol.Protocol; using Scalar.AspNetCore; namespace EvilBrains.EvilCase.Api; @@ -23,6 +27,20 @@ public static class Bootstrap private const string BearerSecurityScheme = "Bearer"; + // IEnumerable, not an array: WithTools would take an array as the target instance of WithTools. + private static readonly IEnumerable McpToolTypes = + [ + typeof(CaseTools), + typeof(ActTools), + typeof(ContactTools), + typeof(LabelTools), + typeof(CommentTools), + typeof(FileTools), + typeof(FileLinkTools), + typeof(SignatureTools), + typeof(SearchTools), + ]; + // A RequestDelegate: a minimal-API handler would have to bind the unused {**path}. private static readonly RequestDelegate NotFound = static async context => { @@ -84,6 +102,15 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi services.AddHttpContextAccessor(); services.AddScoped(); + // Stateless: every request stands alone, so no session outlives a revoked token. + services + .AddMcpServer(static options => options.ServerInfo = new Implementation { Name = "EvilCase", Version = "1.0" }) + .WithHttpTransport(static options => options.Stateless = true) + .WithTools(McpToolTypes) + .WithRequestFilters(static filters => filters + .AddListToolsFilter(McpScopeFilters.ListTools) + .AddCallToolFilter(McpScopeFilters.CallTool)); + return services; } @@ -98,6 +125,10 @@ public static IEndpointRouteBuilder MapEvilCaseApi(this IEndpointRouteBuilder en { endpoints.MapControllers(); + endpoints + .MapMcp(McpRoute.Path) + .RequireAuthorization(ApiTokenPolicy.Name); + // Anonymous in every environment, so an integration reads the contract before it holds a token. endpoints .MapOpenApi() diff --git a/src/Api/EvilCase.Api/Controllers/FileContentAnswer.cs b/src/Api/EvilCase.Api/Controllers/FileContentAnswer.cs new file mode 100644 index 000000000..77ded20d9 --- /dev/null +++ b/src/Api/EvilCase.Api/Controllers/FileContentAnswer.cs @@ -0,0 +1,25 @@ +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Business.Files; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; + +namespace EvilBrains.EvilCase.Api.Controllers; + +internal static class FileContentAnswer +{ + /// + /// Only the types a browser shows natively are inline, under the type the extension gives; any other stored document, PDF + /// included, is an opaque attachment (SDD-012). nosniff comes from SecurityHeadersMiddleware. + /// + public static ActionResult FileContent(this ControllerBase controller, FileDownload download) + { + if (InlineFileTypes.MediaType(download.FileName) is not { } inlineMediaType) + return controller.File(download.Content, "application/octet-stream", download.FileName, enableRangeProcessing: true); + + var disposition = new ContentDispositionHeaderValue("inline"); + disposition.SetHttpFileName(download.FileName); + controller.Response.Headers.ContentDisposition = disposition.ToString(); + + return controller.File(download.Content, inlineMediaType, enableRangeProcessing: true); + } +} diff --git a/src/Api/EvilCase.Api/Controllers/FileLinksController.cs b/src/Api/EvilCase.Api/Controllers/FileLinksController.cs new file mode 100644 index 000000000..85a078634 --- /dev/null +++ b/src/Api/EvilCase.Api/Controllers/FileLinksController.cs @@ -0,0 +1,126 @@ +using System.Diagnostics; +using EvilBrains.EvilCase.Api.Contract.Acts; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Business.Files; +using EvilBrains.EvilCase.Business.Files.Links; +using EvilBrains.EvilCase.Domain.Files; +using EvilBrains.EvilCase.Domain.Users; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace EvilBrains.EvilCase.Api.Controllers; + +/// +/// Anonymous: the one-time link is the credential, and its use runs as the user and tenant that created it. No +/// [GenerateApiClient]: the MCP tools hand the link out, and the generator cannot express a byte stream. +/// +[ApiController] +[AllowAnonymous] +[Route(FileLinkRoute.Template)] +public class FileLinksController : ControllerBase +{ + private const string FileTransferController = "FileTransfer"; + + [HttpGet("")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DownloadLinkedFile( + [FromServices] IFileLinkWriter links, + [FromServices] IFileReader files, + [FromServices] IUserContext userContext, + [FromQuery(Name = FileLinkRoute.SecretParameter)] string secret, + CancellationToken token) + { + var link = await links.ConsumeFileLink(secret, token); + + if (link is not { Kind: FileLinkKind.Download }) + return this.LinkNotFoundProblem(); + + using var entered = userContext.Enter(link.TenantId, link.UserId); + + var download = await files.OpenFileContent(link.TargetId, link.Version, token); + + return download is null + ? this.Problem(statusCode: StatusCodes.Status404NotFound, title: FileProblems.NotFound) + : this.FileContent(download); + } + + [HttpPut("")] + [RequestSizeLimit(FileLimits.MaxUploadBytes)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + public async Task> UploadLinkedFile( + [FromServices] IFileLinkWriter links, + [FromServices] IFileWriter writer, + [FromServices] IUserContext userContext, + [FromQuery(Name = FileLinkRoute.SecretParameter)] string secret, + [FromQuery(Name = FileLinkRoute.FileNameParameter)] string fileName, + CancellationToken token) + { + if (this.Request.ContentLength > FileLimits.MaxUploadBytes) + return this.FileTooLargeProblem(); + + var lastSegment = FileNameRules.LastSegment(fileName); + var mediaType = string.IsNullOrEmpty(this.Request.ContentType) ? null : this.Request.ContentType; + + if (this.InvalidUploadMetadataProblem(lastSegment, mediaType) is { } problem) + return problem; + + var link = await links.ConsumeFileLink(secret, token); + + if (link is null or { Kind: FileLinkKind.Download }) + return this.LinkNotFoundProblem(); + + using var entered = userContext.Enter(link.TenantId, link.UserId); + + var upload = new FileUpload { FileName = lastSegment, MediaType = mediaType, Content = this.Request.Body }; + + return link.Kind switch + { + FileLinkKind.CaseUpload => this.Answer(await writer.UploadCaseFile(link.TargetId, upload, token), CaseProblems.NotFound), + FileLinkKind.ActUpload => this.Answer(await writer.UploadActFile(link.TargetId, upload, token), ActProblems.ActNotFound), + FileLinkKind.VersionUpload => this.Answer(await writer.UploadFileVersion(link.TargetId, upload, token)), + FileLinkKind.Download => throw new UnreachableException(), + _ => throw new UnreachableException(), + }; + } + + private ActionResult Answer(UploadFileResult result, string ownerNotFound) + { + return result.Outcome switch + { + UploadFileOutcome.Uploaded or UploadFileOutcome.VersionAdded => this.CreatedAtAction( + nameof(Controllers.FileTransferController.DownloadFileContent), FileTransferController, new { fileId = result.File!.FileId }, result.File), + UploadFileOutcome.Unchanged => this.Ok(result.File), + UploadFileOutcome.OwnerNotFound => this.Problem(statusCode: StatusCodes.Status404NotFound, title: ownerNotFound), + _ => throw new UnreachableException(), + }; + } + + private ActionResult Answer(FileVersionUploadResult result) + { + return result.Outcome switch + { + FileVersionUploadOutcome.VersionAdded => this.CreatedAtAction( + nameof(Controllers.FileTransferController.DownloadFileContent), + FileTransferController, + new { fileId = result.File!.FileId, version = result.File.Version }, + result.File), + FileVersionUploadOutcome.Unchanged => this.Ok(result.File), + FileVersionUploadOutcome.FileNotFound => this.Problem(statusCode: StatusCodes.Status404NotFound, title: FileProblems.NotFound), + FileVersionUploadOutcome.ExtensionMismatch => this.ExtensionMismatchProblem(), + _ => throw new UnreachableException(), + }; + } + + private ActionResult LinkNotFoundProblem() + { + return this.Problem(detail: "The link is unknown, used or expired.", statusCode: StatusCodes.Status404NotFound, title: FileProblems.LinkNotFound); + } +} diff --git a/src/Api/EvilCase.Api/Controllers/FileTransferController.cs b/src/Api/EvilCase.Api/Controllers/FileTransferController.cs index 40c9555b4..8c928c050 100644 --- a/src/Api/EvilCase.Api/Controllers/FileTransferController.cs +++ b/src/Api/EvilCase.Api/Controllers/FileTransferController.cs @@ -108,19 +108,9 @@ public async Task DownloadFileContent([FromServices] IFileReader f { var download = await files.OpenFileContent(fileId, version, token); - if (download is null) - return this.Problem(statusCode: StatusCodes.Status404NotFound, title: FileProblems.NotFound); - - // Only the types a browser shows natively are inline, under the type the extension gives; any other - // stored document, PDF included, is an opaque attachment (SDD-012). nosniff comes from SecurityHeadersMiddleware. - if (InlineFileTypes.MediaType(download.FileName) is not { } inlineMediaType) - return this.File(download.Content, "application/octet-stream", download.FileName, enableRangeProcessing: true); - - var disposition = new ContentDispositionHeaderValue("inline"); - disposition.SetHttpFileName(download.FileName); - this.Response.Headers.ContentDisposition = disposition.ToString(); - - return this.File(download.Content, inlineMediaType, enableRangeProcessing: true); + return download is null + ? this.Problem(statusCode: StatusCodes.Status404NotFound, title: FileProblems.NotFound) + : this.FileContent(download); } [HttpGet("cases/{caseId:guid}/files/archive")] @@ -194,7 +184,7 @@ await this.SendZip( { if (file.Length > FileLimits.MaxUploadBytes) { - error = this.Problem(detail: "The file exceeds the 100 MB upload limit.", statusCode: StatusCodes.Status413PayloadTooLarge, title: "File too large"); + error = this.FileTooLargeProblem(); return null; } @@ -245,28 +235,4 @@ private async Task SendZip(string fileName, Func write, Action FileLimits.MaxMediaTypeLength }) - { - this.ModelState.AddModelError("mediaType", $"The media type must not exceed {FileLimits.MaxMediaTypeLength} characters."); - return this.ValidationProblem(statusCode: StatusCodes.Status400BadRequest); - } - - return null; - } } diff --git a/src/Api/EvilCase.Api/Controllers/FileUploadAnswer.cs b/src/Api/EvilCase.Api/Controllers/FileUploadAnswer.cs new file mode 100644 index 000000000..8528408d7 --- /dev/null +++ b/src/Api/EvilCase.Api/Controllers/FileUploadAnswer.cs @@ -0,0 +1,40 @@ +using EvilBrains.EvilCase.Api.Contract.Files; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace EvilBrains.EvilCase.Api.Controllers; + +/// +/// The refusals a multipart upload and an upload through a file link share. +/// +internal static class FileUploadAnswer +{ + public static ActionResult FileTooLargeProblem(this ControllerBase controller) + { + return controller.Problem(detail: "The file exceeds the 100 MB upload limit.", statusCode: StatusCodes.Status413PayloadTooLarge, title: "File too large"); + } + + public static ActionResult ExtensionMismatchProblem(this ControllerBase controller) + { + controller.ModelState.AddModelError("fileName", "The new version must keep the extension of the file."); + + return controller.ValidationProblem(statusCode: StatusCodes.Status400BadRequest); + } + + public static ActionResult? InvalidUploadMetadataProblem(this ControllerBase controller, string fileName, string? mediaType) + { + if (FileNameRules.Violation(fileName) is { } violation) + { + controller.ModelState.AddModelError("fileName", violation); + return controller.ValidationProblem(statusCode: StatusCodes.Status400BadRequest); + } + + if (mediaType is { Length: > FileLimits.MaxMediaTypeLength }) + { + controller.ModelState.AddModelError("mediaType", $"The media type must not exceed {FileLimits.MaxMediaTypeLength} characters."); + return controller.ValidationProblem(statusCode: StatusCodes.Status400BadRequest); + } + + return null; + } +} diff --git a/src/Api/EvilCase.Api/EvilCase.Api.csproj b/src/Api/EvilCase.Api/EvilCase.Api.csproj index f21077335..a11f1a2ac 100644 --- a/src/Api/EvilCase.Api/EvilCase.Api.csproj +++ b/src/Api/EvilCase.Api/EvilCase.Api.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Api/EvilCase.Api/Mcp/ActTools.cs b/src/Api/EvilCase.Api/Mcp/ActTools.cs new file mode 100644 index 000000000..83861ebdf --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/ActTools.cs @@ -0,0 +1,111 @@ +using System.ComponentModel; +using System.Diagnostics; +using EvilBrains.EvilCase.Api.Contract.Acts; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Contacts; +using EvilBrains.EvilCase.Api.Contract.Labels; +using EvilBrains.EvilCase.Business.Acts; +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Business.Labels; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class ActTools +{ + [McpServerTool(Name = "list_acts", ReadOnly = true, OpenWorld = false)] + [Description( + "Lists the acts (úkony), one page at a time, with the total count the filter leaves. take (1-100) is required, skip " + + "starts at 0. caseId keeps the acts of one case; caseStatus narrows to acts of cases in that state (All by default); " + + "search matches the act number and title; from and to bound the act date; contactId, direction and labelIds " + + "(every label named) narrow further.")] + public static async Task ListActs(IActReader acts, [Description("Paging, order and filters.")] ActListRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + return await acts.ListActs(request, token); + } + + [McpServerTool(Name = "get_act", ReadOnly = true, OpenWorld = false)] + [Description("Reads one act with its case, contact, labels and the timeline of its case.")] + public static async Task GetAct(IActReader acts, [Description("The act.")] Guid actId, CancellationToken token) + { + return await acts.GetActDetail(actId, token) ?? throw McpProblem.Of(ActProblems.ActNotFound); + } + + [McpServerTool(Name = "create_act", Destructive = false, OpenWorld = false)] + [Description( + "Creates an act on a case; its act number is issued from date. title is required (at most 256 characters), description " + + "at most 4000. direction and contactId come together or not at all. Returns the new act.")] + public static async Task CreateAct(IActWriter writer, [Description("The new act.")] CreateActRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var result = await writer.CreateAct(request, token); + + return result.Outcome switch + { + ActCreateOutcome.Created => result.Act!, + ActCreateOutcome.CaseNotFound => throw McpProblem.Of(CaseProblems.NotFound), + ActCreateOutcome.ContactNotFound => throw McpProblem.Of(ContactProblems.UnknownContact, "The contact named in the request does not exist."), + ActCreateOutcome.LabelNotFound => throw McpProblem.Of(LabelProblems.UnknownLabel, "A label named in the request does not exist."), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "update_act", Destructive = false, OpenWorld = false)] + [Description( + "Replaces every field of an act; read it with get_act first. actNumber must read /yyyyMMdd-nnn and stay " + + "unique; title is required (at most 256 characters), description at most 4000, externalActNumber at most 128. " + + "direction and contactId come together or not at all. labelIds is the whole set the act carries afterwards.")] + public static async Task UpdateAct( + IActWriter writer, [Description("The act.")] Guid actId, [Description("The act's new fields.")] ActEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.UpdateAct(actId, request, token); + + return outcome switch + { + ActUpdateOutcome.Updated => "The act was updated.", + ActUpdateOutcome.NotFound => throw McpProblem.Of(ActProblems.ActNotFound), + ActUpdateOutcome.ContactNotFound => throw McpProblem.Of(ContactProblems.UnknownContact, "The contact named in the request does not exist."), + ActUpdateOutcome.ActNumberTaken => throw McpProblem.Of("Act number taken", "Another act already carries the number."), + ActUpdateOutcome.InvalidActNumber => throw McpProblem.Of( + "One or more validation errors occurred.", "ActNumber: The act number must read /yyyyMMdd-nnn."), + ActUpdateOutcome.LabelNotFound => throw McpProblem.Of(LabelProblems.UnknownLabel, "A label named in the request does not exist."), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "delete_act", Destructive = true, OpenWorld = false)] + [Description("Deletes an act with its files and comments. Cannot be undone.")] + public static async Task DeleteAct(IActWriter writer, [Description("The act.")] Guid actId, CancellationToken token) + { + var outcome = await writer.DeleteAct(actId, token); + + return outcome switch + { + DeleteOutcome.Deleted => "The act was deleted.", + DeleteOutcome.NotFound => throw McpProblem.Of(ActProblems.ActNotFound), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "set_act_labels", Destructive = false, OpenWorld = false)] + [Description("Sets the labels an act carries: labelIds is the whole set afterwards, a label left out is taken off.")] + public static async Task SetActLabels( + ILabelAssignmentWriter writer, [Description("The act.")] Guid actId, [Description("The labels.")] LabelAssignmentRequest request, CancellationToken token) + { + var outcome = await writer.SetActLabels(actId, request, token); + + return outcome switch + { + LabelAssignmentOutcome.Assigned => "The labels of the act were set.", + LabelAssignmentOutcome.OwnerNotFound => throw McpProblem.Of(ActProblems.ActNotFound), + LabelAssignmentOutcome.LabelNotFound => throw McpProblem.Of(LabelProblems.UnknownLabel, "A label named in the request does not exist."), + _ => throw new UnreachableException(), + }; + } +} diff --git a/src/Api/EvilCase.Api/Mcp/CaseTools.cs b/src/Api/EvilCase.Api/Mcp/CaseTools.cs new file mode 100644 index 000000000..a1194b772 --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/CaseTools.cs @@ -0,0 +1,118 @@ +using System.ComponentModel; +using System.Diagnostics; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Contacts; +using EvilBrains.EvilCase.Api.Contract.Labels; +using EvilBrains.EvilCase.Business.Cases; +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Business.Labels; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class CaseTools +{ + [McpServerTool(Name = "list_cases", ReadOnly = true, OpenWorld = false)] + [Description( + "Lists the cases (spisy), one page at a time, with the total count the filter leaves. take (1-100) is required, skip " + + "starts at 0. status defaults to Open (every case not closed); scope defaults to RootOnly (cases without a parent), " + + "parentCaseId lists the subcases of one case instead. search matches the case number and title; from and to bound " + + "the case date; contactId names the contact; labelIds keeps cases carrying every label named.")] + public static async Task ListCases(ICaseReader cases, [Description("Paging, order and filters.")] CaseListRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + return await cases.ListCases(request, token); + } + + [McpServerTool(Name = "count_cases_by_state", ReadOnly = true, OpenWorld = false)] + [Description("Counts the cases in each state: active, waiting on an authority, closed.")] + public static async Task CountCasesByState(ICaseReader cases, CancellationToken token) + { + return await cases.CountCasesByStatus(token); + } + + [McpServerTool(Name = "get_case", ReadOnly = true, OpenWorld = false)] + [Description("Reads one case with its parent path, contact and labels.")] + public static async Task GetCase(ICaseReader cases, [Description("The case.")] Guid caseId, CancellationToken token) + { + return await cases.GetCaseDetail(caseId, token) ?? throw McpProblem.Of(CaseProblems.NotFound); + } + + [McpServerTool(Name = "create_case", Destructive = false, OpenWorld = false)] + [Description( + "Creates a case; its case number is issued from date. title is required (at most 256 characters), description at most " + + "4000. parentCaseId makes it a subcase, contactId and labelIds must name existing ones. Returns the new case.")] + public static async Task CreateCase(ICaseWriter writer, [Description("The new case.")] CreateCaseRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var result = await writer.CreateCase(request, token); + + return result.Outcome switch + { + CaseCreateOutcome.Created => result.Case!, + CaseCreateOutcome.InvalidParent => throw McpProblem.Of(CaseProblems.InvalidParent, "The parent case does not exist."), + CaseCreateOutcome.ContactNotFound => throw McpProblem.Of(ContactProblems.UnknownContact, "The contact named in the request does not exist."), + CaseCreateOutcome.LabelNotFound => throw McpProblem.Of(LabelProblems.UnknownLabel, "A label named in the request does not exist."), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "update_case", Destructive = false, OpenWorld = false)] + [Description( + "Replaces every field of a case; read it with get_case first. caseNumber must read EC/yyyyMMdd-nnn and stay unique; " + + "title is required (at most 256 characters), description at most 4000, externalCaseNumber at most 128. labelIds is " + + "the whole set the case carries afterwards. The parent must be neither the case nor one of its subcases.")] + public static async Task UpdateCase( + ICaseWriter writer, [Description("The case.")] Guid caseId, [Description("The case's new fields.")] CaseEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.UpdateCase(caseId, request, token); + + return outcome switch + { + CaseUpdateOutcome.Updated => "The case was updated.", + CaseUpdateOutcome.NotFound => throw McpProblem.Of(CaseProblems.NotFound), + CaseUpdateOutcome.CaseNumberTaken => throw McpProblem.Of("Case number taken", "Another case already carries the number."), + CaseUpdateOutcome.InvalidCaseNumber => throw McpProblem.Of("One or more validation errors occurred.", "CaseNumber: The case number must read EC/yyyyMMdd-nnn."), + CaseUpdateOutcome.InvalidParent => throw McpProblem.Of( + CaseProblems.InvalidParent, "The parent case must exist and must be neither the case itself nor one of its subordinates."), + CaseUpdateOutcome.ContactNotFound => throw McpProblem.Of(ContactProblems.UnknownContact, "The contact named in the request does not exist."), + CaseUpdateOutcome.LabelNotFound => throw McpProblem.Of(LabelProblems.UnknownLabel, "A label named in the request does not exist."), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "delete_case", Destructive = true, OpenWorld = false)] + [Description("Deletes a case with its subcases, acts, files and comments. Cannot be undone.")] + public static async Task DeleteCase(ICaseWriter writer, [Description("The case.")] Guid caseId, CancellationToken token) + { + var outcome = await writer.DeleteCase(caseId, token); + + return outcome switch + { + DeleteOutcome.Deleted => "The case was deleted.", + DeleteOutcome.NotFound => throw McpProblem.Of(CaseProblems.NotFound), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "set_case_labels", Destructive = false, OpenWorld = false)] + [Description("Sets the labels a case carries: labelIds is the whole set afterwards, a label left out is taken off.")] + public static async Task SetCaseLabels( + ILabelAssignmentWriter writer, [Description("The case.")] Guid caseId, [Description("The labels.")] LabelAssignmentRequest request, CancellationToken token) + { + var outcome = await writer.SetCaseLabels(caseId, request, token); + + return outcome switch + { + LabelAssignmentOutcome.Assigned => "The labels of the case were set.", + LabelAssignmentOutcome.OwnerNotFound => throw McpProblem.Of(CaseProblems.NotFound), + LabelAssignmentOutcome.LabelNotFound => throw McpProblem.Of(LabelProblems.UnknownLabel, "A label named in the request does not exist."), + _ => throw new UnreachableException(), + }; + } +} diff --git a/src/Api/EvilCase.Api/Mcp/CommentTools.cs b/src/Api/EvilCase.Api/Mcp/CommentTools.cs new file mode 100644 index 000000000..58e24956a --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/CommentTools.cs @@ -0,0 +1,124 @@ +using System.ComponentModel; +using System.Diagnostics; +using EvilBrains.EvilCase.Api.Contract.Acts; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Comments; +using EvilBrains.EvilCase.Business.Comments; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class CommentTools +{ + private const string Written = "The comment was saved."; + + [McpServerTool(Name = "list_case_comments", ReadOnly = true, OpenWorld = false)] + [Description("Lists the comments of a case, oldest first; isAuthor says which ones the token's user wrote.")] + public static async Task ListCaseComments(ICommentReader comments, [Description("The case.")] Guid caseId, CancellationToken token) + { + var items = await comments.ListCaseComments(caseId, token) ?? throw McpProblem.Of(CaseProblems.NotFound); + + return new CommentListResponse { Items = items }; + } + + [McpServerTool(Name = "list_act_comments", ReadOnly = true, OpenWorld = false)] + [Description("Lists the comments of an act, oldest first; isAuthor says which ones the token's user wrote.")] + public static async Task ListActComments(ICommentReader comments, [Description("The act.")] Guid actId, CancellationToken token) + { + var items = await comments.ListActComments(actId, token) ?? throw McpProblem.Of(ActProblems.ActNotFound); + + return new CommentListResponse { Items = items }; + } + + [McpServerTool(Name = "add_case_comment", Destructive = false, OpenWorld = false)] + [Description("Adds a comment to a case under the token's user. body is required.")] + public static async Task AddCaseComment( + ICommentWriter writer, [Description("The case.")] Guid caseId, [Description("The comment.")] CommentEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.AddCaseComment(caseId, request, token); + + return outcome switch + { + CommentWriteOutcome.Written => Written, + CommentWriteOutcome.NotFound => throw McpProblem.Of(CaseProblems.NotFound), + CommentWriteOutcome.NotAuthor => throw new UnreachableException(), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "add_act_comment", Destructive = false, OpenWorld = false)] + [Description("Adds a comment to an act under the token's user. body is required.")] + public static async Task AddActComment( + ICommentWriter writer, [Description("The act.")] Guid actId, [Description("The comment.")] CommentEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.AddActComment(actId, request, token); + + return outcome switch + { + CommentWriteOutcome.Written => Written, + CommentWriteOutcome.NotFound => throw McpProblem.Of(ActProblems.ActNotFound), + CommentWriteOutcome.NotAuthor => throw new UnreachableException(), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "update_case_comment", Destructive = false, OpenWorld = false)] + [Description("Rewrites a comment of a case; only its author may. body is required.")] + public static async Task UpdateCaseComment( + ICommentWriter writer, + [Description("The case.")] Guid caseId, + [Description("The comment.")] Guid commentId, + [Description("The comment's new text.")] CommentEditRequest request, + CancellationToken token) + { + McpProblem.Validate(request); + + return Answer(await writer.UpdateCaseComment(caseId, commentId, request, token)); + } + + [McpServerTool(Name = "update_act_comment", Destructive = false, OpenWorld = false)] + [Description("Rewrites a comment of an act; only its author may. body is required.")] + public static async Task UpdateActComment( + ICommentWriter writer, + [Description("The act.")] Guid actId, + [Description("The comment.")] Guid commentId, + [Description("The comment's new text.")] CommentEditRequest request, + CancellationToken token) + { + McpProblem.Validate(request); + + return Answer(await writer.UpdateActComment(actId, commentId, request, token)); + } + + [McpServerTool(Name = "delete_case_comment", Destructive = true, OpenWorld = false)] + [Description("Deletes a comment of a case; only its author may. Cannot be undone.")] + public static async Task DeleteCaseComment( + ICommentWriter writer, [Description("The case.")] Guid caseId, [Description("The comment.")] Guid commentId, CancellationToken token) + { + return Answer(await writer.DeleteCaseComment(caseId, commentId, token)); + } + + [McpServerTool(Name = "delete_act_comment", Destructive = true, OpenWorld = false)] + [Description("Deletes a comment of an act; only its author may. Cannot be undone.")] + public static async Task DeleteActComment( + ICommentWriter writer, [Description("The act.")] Guid actId, [Description("The comment.")] Guid commentId, CancellationToken token) + { + return Answer(await writer.DeleteActComment(actId, commentId, token)); + } + + private static string Answer(CommentWriteOutcome outcome) + { + return outcome switch + { + CommentWriteOutcome.Written => Written, + CommentWriteOutcome.NotFound => throw McpProblem.Of("Comment not found"), + CommentWriteOutcome.NotAuthor => throw McpProblem.Of("Not the author", "Only the author edits or deletes a comment."), + _ => throw new UnreachableException(), + }; + } +} diff --git a/src/Api/EvilCase.Api/Mcp/ContactTools.cs b/src/Api/EvilCase.Api/Mcp/ContactTools.cs new file mode 100644 index 000000000..b0e45946f --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/ContactTools.cs @@ -0,0 +1,75 @@ +using System.ComponentModel; +using System.Diagnostics; +using EvilBrains.EvilCase.Api.Contract.Contacts; +using EvilBrains.EvilCase.Business.Contacts; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class ContactTools +{ + [McpServerTool(Name = "list_contacts", ReadOnly = true, OpenWorld = false)] + [Description( + "Lists the contacts (authorities, people, companies), one page at a time, with the total count the filter leaves. take " + + "(1-100) is required, skip starts at 0. search matches the name and the data box id; kind narrows to one kind.")] + public static async Task ListContacts( + IContactReader contacts, [Description("Paging, order and filters.")] ContactListRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + return await contacts.ListContacts(request, token); + } + + [McpServerTool(Name = "get_contact", ReadOnly = true, OpenWorld = false)] + [Description("Reads one contact.")] + public static async Task GetContact(IContactReader contacts, [Description("The contact.")] Guid contactId, CancellationToken token) + { + return await contacts.GetContactDetail(contactId, token) ?? throw McpProblem.Of(ContactProblems.NotFound); + } + + [McpServerTool(Name = "create_contact", Destructive = false, OpenWorld = false)] + [Description( + "Creates a contact. name is required (at most 256 characters), dataBoxId at most 16, address at most 1024. Returns the " + + "new contact.")] + public static async Task CreateContact(IContactWriter writer, [Description("The new contact.")] ContactEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + return await writer.CreateContact(request, token); + } + + [McpServerTool(Name = "update_contact", Destructive = false, OpenWorld = false)] + [Description( + "Replaces every field of a contact; read it with get_contact first. name is required (at most 256 characters), dataBoxId " + + "at most 16, address at most 1024.")] + public static async Task UpdateContact( + IContactWriter writer, [Description("The contact.")] Guid contactId, [Description("The contact's new fields.")] ContactEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.UpdateContact(contactId, request, token); + + return outcome switch + { + ContactUpdateOutcome.Updated => "The contact was updated.", + ContactUpdateOutcome.NotFound => throw McpProblem.Of(ContactProblems.NotFound), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "delete_contact", Destructive = true, OpenWorld = false)] + [Description("Deletes a contact no case or act names; a named one is refused. Cannot be undone.")] + public static async Task DeleteContact(IContactWriter writer, [Description("The contact.")] Guid contactId, CancellationToken token) + { + var outcome = await writer.DeleteContact(contactId, token); + + return outcome switch + { + ContactDeleteOutcome.Deleted => "The contact was deleted.", + ContactDeleteOutcome.NotFound => throw McpProblem.Of(ContactProblems.NotFound), + ContactDeleteOutcome.Referenced => throw McpProblem.Of("Contact in use", "The contact is named by a case or an act."), + _ => throw new UnreachableException(), + }; + } +} diff --git a/src/Api/EvilCase.Api/Mcp/FileLinkTools.cs b/src/Api/EvilCase.Api/Mcp/FileLinkTools.cs new file mode 100644 index 000000000..5a3a80844 --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/FileLinkTools.cs @@ -0,0 +1,114 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Security.Claims; +using EvilBrains.EvilCase.Api.Contract.Acts; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Api.Contract.User; +using EvilBrains.EvilCase.Business.Files.Links; +using EvilBrains.EvilCase.Domain.Files; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +/// +/// File bytes never travel through MCP: a tool hands out a one-time link, which plain HTTP spends. +/// +[McpServerToolType] +internal static class FileLinkTools +{ + private const string LocalPath = ""; + + [McpServerTool(Name = "create_file_upload_link", Destructive = false, OpenWorld = false)] + [Description( + "Creates a one-time link that uploads a file: a new file onto a case (caseId) or an act (actId), or a new version of a " + + "file (fileId); name exactly one. fileName is the name the upload arrives under (at most 256 characters); a new " + + "version keeps the extension of its file. Send the raw bytes as an HTTP PUT to the URL, as the curl example does; " + + "at most 100 MB. The link is valid 10 minutes and spent by its first use. The upload answers with the file; 200 " + + "where the content equals the current version and nothing was stored.")] + public static async Task CreateFileUploadLink( + IFileLinkWriter writer, + IHttpContextAccessor httpContextAccessor, + [Description("The name the file arrives under.")] string fileName, + [Description("The case a new file goes onto.")] Guid? caseId = null, + [Description("The act a new file goes onto.")] Guid? actId = null, + [Description("The file a new version goes onto.")] Guid? fileId = null, + CancellationToken token = default) + { + var (kind, targetId) = (caseId, actId, fileId) switch + { + ({ } id, null, null) => (FileLinkKind.CaseUpload, id), + (null, { } id, null) => (FileLinkKind.ActUpload, id), + (null, null, { } id) => (FileLinkKind.VersionUpload, id), + _ => throw McpProblem.Of("One or more validation errors occurred.", "Name exactly one of caseId, actId and fileId."), + }; + + var lastSegment = FileNameRules.LastSegment(fileName); + + if (FileNameRules.Violation(lastSegment) is { } violation) + throw McpProblem.Of("One or more validation errors occurred.", $"fileName: {violation}"); + + var (link, expires) = await CreateFileLink(writer, httpContextAccessor.HttpContext!, kind, targetId, version: null, token); + var url = $"{link}&{FileLinkRoute.FileNameParameter}={Uri.EscapeDataString(lastSegment)}"; + + return new FileLink + { + Method = HttpMethods.Put, + Url = url, + Expires = expires, + Curl = $"curl --fail --upload-file '{LocalPath}' '{url}'", + }; + } + + [McpServerTool(Name = "create_file_download_link", ReadOnly = true, OpenWorld = false)] + [Description( + "Creates a one-time link that downloads the content of a file version with an HTTP GET, as the curl example does. The " + + "link is valid 10 minutes and spent by its first use. read_file_text reads the text of a file without a download.")] + public static async Task CreateFileDownloadLink( + IFileLinkWriter writer, + IHttpContextAccessor httpContextAccessor, + [Description("The file.")] Guid fileId, + [Description("The version; the current one where omitted.")] int? version = null, + CancellationToken token = default) + { + var (url, expires) = await CreateFileLink(writer, httpContextAccessor.HttpContext!, FileLinkKind.Download, fileId, version, token); + + return new FileLink + { + Method = HttpMethods.Get, + Url = url, + Expires = expires, + Curl = $"curl --fail --output '{LocalPath}' '{url}'", + }; + } + + /// + /// The link is absolute, from the scheme and host the MCP request arrived at; the host filter and the forwarded headers + /// vouch for both. The token that asks owns the link, so its revocation takes the link along. + /// + private static async Task<(string Url, DateTime Expires)> CreateFileLink( + IFileLinkWriter writer, HttpContext httpContext, FileLinkKind kind, Guid targetId, int? version, CancellationToken token) + { + var apiTokenId = Guid.Parse(httpContext.User.FindFirstValue(AuthClaims.ApiTokenId)!, CultureInfo.InvariantCulture); + + var result = await writer.CreateFileLink(kind, targetId, version, apiTokenId, token); + + if (result.Outcome == FileLinkCreateOutcome.TargetNotFound) + { + throw kind switch + { + FileLinkKind.CaseUpload => McpProblem.Of(CaseProblems.NotFound), + FileLinkKind.ActUpload => McpProblem.Of(ActProblems.ActNotFound), + FileLinkKind.VersionUpload => McpProblem.Of(FileProblems.NotFound), + FileLinkKind.Download => McpProblem.Of(version is null ? FileProblems.NotFound : FileProblems.VersionNotFound), + _ => throw new UnreachableException(), + }; + } + + var request = httpContext.Request; + var url = $"{request.Scheme}://{request.Host}{request.PathBase}{FileLinkRoute.Path}?{FileLinkRoute.SecretParameter}={result.Secret}"; + + return (url, result.Expires!.Value); + } +} diff --git a/src/Api/EvilCase.Api/Mcp/FileTools.cs b/src/Api/EvilCase.Api/Mcp/FileTools.cs new file mode 100644 index 000000000..ad9f3b3fb --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/FileTools.cs @@ -0,0 +1,167 @@ +using System.ComponentModel; +using System.Diagnostics; +using EvilBrains.EvilCase.Api.Contract.Acts; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Business.Files; +using EvilBrains.EvilCase.Business.Files.Text; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class FileTools +{ + [McpServerTool(Name = "list_case_files", ReadOnly = true, OpenWorld = false)] + [Description("Lists the files of a case in upload order, each with its current version and the signature file that signs it.")] + public static async Task ListCaseFiles(IFileReader files, [Description("The case.")] Guid caseId, CancellationToken token) + { + var items = await files.ListCaseFiles(caseId, token) ?? throw McpProblem.Of(CaseProblems.NotFound); + + return new FileListResponse { Items = items }; + } + + [McpServerTool(Name = "list_act_files", ReadOnly = true, OpenWorld = false)] + [Description("Lists the files of an act in upload order, each with its current version and the signature file that signs it.")] + public static async Task ListActFiles(IFileReader files, [Description("The act.")] Guid actId, CancellationToken token) + { + var items = await files.ListActFiles(actId, token) ?? throw McpProblem.Of(ActProblems.ActNotFound); + + return new FileListResponse { Items = items }; + } + + [McpServerTool(Name = "list_file_versions", ReadOnly = true, OpenWorld = false)] + [Description("Lists the versions of a file, newest first, with size, upload time and uploader.")] + public static async Task ListFileVersions(IFileReader files, [Description("The file.")] Guid fileId, CancellationToken token) + { + var items = await files.ListFileVersions(fileId, token) ?? throw McpProblem.Of(FileProblems.NotFound); + + return new FileVersionListResponse { Items = items }; + } + + [McpServerTool(Name = "read_file_text", ReadOnly = true, OpenWorld = false)] + [Description( + "Reads the text of a file version: a text format (txt, csv, md, json, xml, html, yaml, log) as stored, the text layer of " + + "a PDF, the paragraphs of a DOCX. The text comes in pages of 50,000 characters with the page count and total length; " + + "read the next page until the last. A scan or an image has no text and says so. A file over 30 MB is not read. " + + "Data messages (ZFO), e-mails and p7m content are not read.")] + public static async Task ReadFileText( + IFileTextReader reader, + [Description("The file.")] Guid fileId, + [Description("The version; the current one where omitted.")] int? version, + [Description("The page, from 1.")] int page = 1, + CancellationToken token = default) + { + var result = await reader.ReadFileText(fileId, version, page, token); + + return result.Outcome switch + { + FileTextOutcome.Read => result.Page!, + FileTextOutcome.FileNotFound => throw McpProblem.Of(FileProblems.NotFound), + FileTextOutcome.UnsupportedFormat => throw McpProblem.Of("No text", "Text is read from text formats, PDF and DOCX only."), + FileTextOutcome.TooLarge => throw McpProblem.Of("File too large", "The text of a file over 30 MB is not read."), + FileTextOutcome.NoText => throw McpProblem.Of("No text", "The file carries no text layer; it is a scan or an image."), + FileTextOutcome.Unreadable => throw McpProblem.Of("Unreadable file", "The content does not parse as its extension says."), + FileTextOutcome.PageNotFound => throw McpProblem.Of("Page not found", string.Create(CultureInfo.InvariantCulture, $"The text has {result.PageCount} page(s).")), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "rename_case_file", Destructive = false, OpenWorld = false)] + [Description( + "Renames a file of a case. Only the last path segment of fileName is kept; it must not be empty, must not exceed 256 " + + "characters and must be unique among the files of the case.")] + public static async Task RenameCaseFile( + IFileWriter writer, + [Description("The case.")] Guid caseId, + [Description("The file.")] Guid fileId, + [Description("The new name.")] FileRenameRequest request, + CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.RenameCaseFile(caseId, fileId, ValidFileName(request.FileName), token); + + return Answer(outcome, "Another file of the case already carries the name."); + } + + [McpServerTool(Name = "rename_act_file", Destructive = false, OpenWorld = false)] + [Description( + "Renames a file of an act. Only the last path segment of fileName is kept; it must not be empty, must not exceed 256 " + + "characters and must be unique among the files of the act.")] + public static async Task RenameActFile( + IFileWriter writer, + [Description("The act.")] Guid actId, + [Description("The file.")] Guid fileId, + [Description("The new name.")] FileRenameRequest request, + CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.RenameActFile(actId, fileId, ValidFileName(request.FileName), token); + + return Answer(outcome, "Another file of the act already carries the name."); + } + + [McpServerTool(Name = "delete_case_file", Destructive = true, OpenWorld = false)] + [Description("Deletes a file of a case with every version. Cannot be undone.")] + public static async Task DeleteCaseFile(IFileWriter writer, [Description("The case.")] Guid caseId, [Description("The file.")] Guid fileId, CancellationToken token) + { + return Answer(await writer.DeleteCaseFile(caseId, fileId, token)); + } + + [McpServerTool(Name = "delete_act_file", Destructive = true, OpenWorld = false)] + [Description("Deletes a file of an act with every version. Cannot be undone.")] + public static async Task DeleteActFile(IFileWriter writer, [Description("The act.")] Guid actId, [Description("The file.")] Guid fileId, CancellationToken token) + { + return Answer(await writer.DeleteActFile(actId, fileId, token)); + } + + [McpServerTool(Name = "delete_file_version", Destructive = true, OpenWorld = false)] + [Description("Deletes one version of a file. The only version is deleted only with the file (delete_case_file, delete_act_file). Cannot be undone.")] + public static async Task DeleteFileVersion( + IFileWriter writer, [Description("The file.")] Guid fileId, [Description("The version number.")] int number, CancellationToken token) + { + var outcome = await writer.DeleteFileVersion(fileId, number, token); + + return outcome switch + { + FileVersionDeleteOutcome.Deleted => "The version was deleted.", + FileVersionDeleteOutcome.NotFound => throw McpProblem.Of(FileProblems.VersionNotFound), + FileVersionDeleteOutcome.LastVersion => throw McpProblem.Of(FileProblems.LastVersion, "The only version of a file is deleted only with the file."), + _ => throw new UnreachableException(), + }; + } + + private static string ValidFileName(string fileName) + { + var lastSegment = FileNameRules.LastSegment(fileName); + + if (FileNameRules.Violation(lastSegment) is { } violation) + throw McpProblem.Of("One or more validation errors occurred.", $"fileName: {violation}"); + + return lastSegment; + } + + private static string Answer(FileRenameOutcome outcome, string nameTaken) + { + return outcome switch + { + FileRenameOutcome.Renamed => "The file was renamed.", + FileRenameOutcome.NotFound => throw McpProblem.Of(FileProblems.NotFound), + FileRenameOutcome.NameTaken => throw McpProblem.Of(FileProblems.NameTaken, nameTaken), + _ => throw new UnreachableException(), + }; + } + + private static string Answer(DeleteOutcome outcome) + { + return outcome switch + { + DeleteOutcome.Deleted => "The file was deleted.", + DeleteOutcome.NotFound => throw McpProblem.Of(FileProblems.NotFound), + _ => throw new UnreachableException(), + }; + } +} diff --git a/src/Api/EvilCase.Api/Mcp/LabelTools.cs b/src/Api/EvilCase.Api/Mcp/LabelTools.cs new file mode 100644 index 000000000..5e71e097c --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/LabelTools.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; +using System.Diagnostics; +using EvilBrains.EvilCase.Api.Contract.Labels; +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Business.Labels; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class LabelTools +{ + [McpServerTool(Name = "list_labels", ReadOnly = true, OpenWorld = false)] + [Description("Lists every label of the tenant with how many cases and acts carry it. set_case_labels and set_act_labels assign them.")] + public static async Task ListLabels(ILabelReader labels, CancellationToken token) + { + return new LabelListResponse { Items = await labels.ListLabels(token) }; + } + + [McpServerTool(Name = "create_label", Destructive = false, OpenWorld = false)] + [Description("Creates a label. name is required, at most 64 characters, and unique in the tenant. Returns the new label.")] + public static async Task CreateLabel(ILabelWriter writer, [Description("The new label.")] LabelEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var result = await writer.CreateLabel(request, token); + + return result.Outcome switch + { + LabelCreateOutcome.Created => result.Label!, + LabelCreateOutcome.NameTaken => throw McpProblem.Of(LabelProblems.NameTaken, "Another label already carries the name."), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "update_label", Destructive = false, OpenWorld = false)] + [Description("Renames or recolours a label. name is required, at most 64 characters, and unique in the tenant.")] + public static async Task UpdateLabel( + ILabelWriter writer, [Description("The label.")] Guid labelId, [Description("The label's new fields.")] LabelEditRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + var outcome = await writer.UpdateLabel(labelId, request, token); + + return outcome switch + { + LabelUpdateOutcome.Updated => "The label was updated.", + LabelUpdateOutcome.NotFound => throw McpProblem.Of(LabelProblems.NotFound), + LabelUpdateOutcome.NameTaken => throw McpProblem.Of(LabelProblems.NameTaken, "Another label already carries the name."), + _ => throw new UnreachableException(), + }; + } + + [McpServerTool(Name = "delete_label", Destructive = true, OpenWorld = false)] + [Description("Deletes a label and takes it off every case and act that carries it. Cannot be undone.")] + public static async Task DeleteLabel(ILabelWriter writer, [Description("The label.")] Guid labelId, CancellationToken token) + { + var outcome = await writer.DeleteLabel(labelId, token); + + return outcome switch + { + DeleteOutcome.Deleted => "The label was deleted.", + DeleteOutcome.NotFound => throw McpProblem.Of(LabelProblems.NotFound), + _ => throw new UnreachableException(), + }; + } +} diff --git a/src/Api/EvilCase.Api/Mcp/McpProblem.cs b/src/Api/EvilCase.Api/Mcp/McpProblem.cs new file mode 100644 index 000000000..c86915ec1 --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/McpProblem.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using ModelContextProtocol; + +namespace EvilBrains.EvilCase.Api.Mcp; + +/// +/// A tool answers a refusal as a tool error carrying the Problem Details title and detail its REST endpoint answers with. +/// +internal static class McpProblem +{ + public static McpException Of(string title, string? detail = null) + { + return new McpException(detail is null ? title : $"{title}: {detail}"); + } + + /// + /// The data annotations REST model validation checks on the same DTO. + /// + public static void Validate(object request) + { + var results = new List(); + + if (Validator.TryValidateObject(request, new ValidationContext(request), results, validateAllProperties: true)) + return; + + var errors = results.Select(static result => $"{string.Join(", ", result.MemberNames)}: {result.ErrorMessage}"); + + throw Of("One or more validation errors occurred.", string.Join(' ', errors)); + } +} diff --git a/src/Api/EvilCase.Api/Mcp/McpScopeFilters.cs b/src/Api/EvilCase.Api/Mcp/McpScopeFilters.cs new file mode 100644 index 000000000..2db6e91a0 --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/McpScopeFilters.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using EvilBrains.EvilCase.Api.Contract.User; +using EvilBrains.EvilCase.Domain.ApiTokens; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +/// +/// A read-only token sees and calls only the tools annotated read-only; a tool without the annotation counts as a write. +/// +internal static class McpScopeFilters +{ + public static McpRequestHandler ListTools(McpRequestHandler next) + { + return async (context, token) => + { + var result = await next(context, token); + + if (!CanWrite(context.User)) + result.Tools = [.. result.Tools.Where(static tool => tool.Annotations?.ReadOnlyHint == true)]; + + return result; + }; + } + + public static McpRequestHandler CallTool(McpRequestHandler next) + { + return async (context, token) => + { + var tools = context.Server.ServerOptions.ToolCollection; + + if (!CanWrite(context.User) + && tools is not null + && tools.TryGetPrimitive(context.Params.Name, out var tool) + && tool.ProtocolTool.Annotations?.ReadOnlyHint != true) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "Forbidden: A read-only API token cannot call a write tool." }], + }; + } + + return await next(context, token); + }; + } + + private static bool CanWrite(ClaimsPrincipal? user) + { + return string.Equals(user?.FindFirstValue(AuthClaims.ApiTokenScope), nameof(ApiTokenScope.ReadWrite), StringComparison.Ordinal); + } +} diff --git a/src/Api/EvilCase.Api/Mcp/SearchTools.cs b/src/Api/EvilCase.Api/Mcp/SearchTools.cs new file mode 100644 index 000000000..8ba572cdc --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/SearchTools.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; +using EvilBrains.EvilCase.Api.Contract.Search; +using EvilBrains.EvilCase.Business.Search; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class SearchTools +{ + [McpServerTool(Name = "search", ReadOnly = true, OpenWorld = false)] + [Description( + "Searches cases, acts, contacts, file names and comments at once. q is 2 to 256 characters with no space at either end. " + + "Each group carries at most 3 hits and its total count; the list tools page through the rest.")] + public static async Task Search(ISearchReader search, [Description("The term.")] SearchRequest request, CancellationToken token) + { + McpProblem.Validate(request); + + return await search.Search(request, token); + } +} diff --git a/src/Api/EvilCase.Api/Mcp/SignatureTools.cs b/src/Api/EvilCase.Api/Mcp/SignatureTools.cs new file mode 100644 index 000000000..5a85c0cb8 --- /dev/null +++ b/src/Api/EvilCase.Api/Mcp/SignatureTools.cs @@ -0,0 +1,49 @@ +using System.ComponentModel; +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Business.Files.Signatures; +using ModelContextProtocol.Server; + +namespace EvilBrains.EvilCase.Api.Mcp; + +[McpServerToolType] +internal static class SignatureTools +{ + [McpServerTool(Name = "read_file_signature", ReadOnly = true, OpenWorld = true)] + [Description( + "Verifies the ISDS seal of a data message (ZFO): state, certificate, timestamp and revocation. Asks the certificate " + + "authority for the revocation status. Any other file reads as not a data message; one over 30 MB is not read.")] + public static async Task ReadFileSignature( + IFileSignatureReader signatures, + [Description("The file.")] Guid fileId, + [Description("The version; the current one where omitted.")] int? version, + CancellationToken token) + { + return await signatures.ReadFileSignature(fileId, version, token) ?? throw McpProblem.Of(FileProblems.NotFound); + } + + [McpServerTool(Name = "list_file_signers", ReadOnly = true, OpenWorld = false)] + [Description( + "Lists the signers of a signed file (p7m, p7s, at most 50, files up to 30 MB) without verifying them; a detached " + + "signature also names the file it signs.")] + public static async Task ListFileSigners( + IFileSignerReader signers, + [Description("The file.")] Guid fileId, + [Description("The version; the current one where omitted.")] int? version, + CancellationToken token) + { + return await signers.ListFileSigners(fileId, version, token) ?? throw McpProblem.Of(FileProblems.NotFound); + } + + [McpServerTool(Name = "verify_file_signers", ReadOnly = true, OpenWorld = true)] + [Description( + "Verifies every signer of a signed file (p7m, p7s): signature, certificate chain, qualification and revocation, which " + + "asks the certificate authorities. Same limits as list_file_signers.")] + public static async Task VerifyFileSigners( + IFileSignerVerifier verifier, + [Description("The file.")] Guid fileId, + [Description("The version; the current one where omitted.")] int? version, + CancellationToken token) + { + return await verifier.VerifyFileSigners(fileId, version, token) ?? throw McpProblem.Of(FileProblems.NotFound); + } +} diff --git a/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs b/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs index ca56b8cf8..42d8d61ec 100644 --- a/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs +++ b/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using EvilBrains.EvilCase.Api.Contract.ApiTokens; +using EvilBrains.EvilCase.Api.Contract.Mcp; using EvilBrains.EvilCase.Api.Contract.User; using EvilBrains.EvilCase.Domain.ApiTokens; using Microsoft.AspNetCore.Http; @@ -10,7 +11,7 @@ namespace EvilBrains.EvilCase.Auth; /// /// Runs after authentication on every path, anonymous endpoints included: a token never reaches sign-in or -/// its own management, and a read-only token never writes. Everything a token's request logs carries the +/// its own management, and a read-only token never writes through the REST API. Everything a token's request logs carries the /// token's id and name. /// internal sealed class ApiTokenBoundaryMiddleware(RequestDelegate next, ILogger logger) @@ -61,6 +62,10 @@ await problemDetails.TryWriteAsync(new() return "An API token cannot reach sign-in, sessions or API token management."; } + // Every MCP message is a POST; the MCP server holds a read-only token to its read tools itself. + if (request.Path.StartsWithSegments(McpRoute.Path, StringComparison.OrdinalIgnoreCase)) + return null; + var write = !HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method) && !HttpMethods.IsOptions(request.Method); if (write && !string.Equals(user.FindFirstValue(AuthClaims.ApiTokenScope), nameof(ApiTokenScope.ReadWrite), StringComparison.Ordinal)) diff --git a/src/Common/EvilCase.Auth/ApiTokenPolicy.cs b/src/Common/EvilCase.Auth/ApiTokenPolicy.cs new file mode 100644 index 000000000..4cafc91a6 --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenPolicy.cs @@ -0,0 +1,9 @@ +namespace EvilBrains.EvilCase.Auth; + +/// +/// Authenticates by an API token alone: neither the cookie nor a JWT satisfies it. +/// +public static class ApiTokenPolicy +{ + public const string Name = "ApiToken"; +} diff --git a/src/Common/EvilCase.Auth/Bootstrap.cs b/src/Common/EvilCase.Auth/Bootstrap.cs index 0ffc7b218..c9b9315b3 100644 --- a/src/Common/EvilCase.Auth/Bootstrap.cs +++ b/src/Common/EvilCase.Auth/Bootstrap.cs @@ -56,7 +56,13 @@ public static IServiceCollection AddEvilCaseAuth(this IServiceCollection service // Default deny: an unattributed endpoint fails closed; open ones say [AllowAnonymous]. services .AddAuthorizationBuilder() - .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()); + .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()) + .AddPolicy( + ApiTokenPolicy.Name, + static policy => policy + .AddAuthenticationSchemes(ApiTokenAuthenticationHandler.SchemeName) + .RequireAuthenticatedUser() + .RequireClaim(AuthClaims.ApiTokenId)); return services; } diff --git a/src/EvilCase.Host/CrossSiteWriteMiddleware.cs b/src/EvilCase.Host/CrossSiteWriteMiddleware.cs index 1989e322b..7fe3754fc 100644 --- a/src/EvilCase.Host/CrossSiteWriteMiddleware.cs +++ b/src/EvilCase.Host/CrossSiteWriteMiddleware.cs @@ -1,9 +1,11 @@ +using EvilBrains.EvilCase.Api.Contract.Mcp; + namespace EvilBrains.EvilCase.Host; /// -/// Rejects an API write the browser says came from another origin. The auth cookies are SameSite=Strict; -/// this is the second line. A request with neither Sec-Fetch-Site nor Origin is not from a -/// browser and passes. +/// Rejects an API write, and any request to the MCP endpoint, that the browser says came from another origin. The auth +/// cookies are SameSite=Strict; this is the second line. A request with neither Sec-Fetch-Site nor Origin is +/// not from a browser and passes. /// internal sealed class CrossSiteWriteMiddleware(RequestDelegate next) { @@ -11,7 +13,7 @@ internal sealed class CrossSiteWriteMiddleware(RequestDelegate next) public async Task Invoke(HttpContext context) { - if (IsApiWrite(context.Request) && !IsSameOrigin(context.Request)) + if ((IsApiWrite(context.Request) || IsMcp(context.Request)) && !IsSameOrigin(context.Request)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; @@ -42,6 +44,11 @@ private static bool IsApiWrite(HttpRequest request) && !HttpMethods.IsOptions(request.Method); } + private static bool IsMcp(HttpRequest request) + { + return request.Path.StartsWithSegments(McpRoute.Path, StringComparison.OrdinalIgnoreCase); + } + private static bool IsSameOrigin(HttpRequest request) { if (request.Headers.TryGetValue("Sec-Fetch-Site", out var site) && site.Count > 0) diff --git a/src/EvilCase.Host/Program.cs b/src/EvilCase.Host/Program.cs index 054504950..94aacf1b1 100644 --- a/src/EvilCase.Host/Program.cs +++ b/src/EvilCase.Host/Program.cs @@ -4,6 +4,7 @@ using EvilBrains.Collections; using EvilBrains.EvilCase.Api; using EvilBrains.EvilCase.Api.Contract.Logging; +using EvilBrains.EvilCase.Api.Contract.Mcp; using EvilBrains.EvilCase.Api.Contract.User; using EvilBrains.EvilCase.Api.HealthChecks; using EvilBrains.EvilCase.Auth; @@ -15,6 +16,7 @@ using EvilBrains.Logging.Contract; using Microsoft.AspNetCore.HttpOverrides; using Serilog; +using Serilog.Events; CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; @@ -32,7 +34,12 @@ .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .Enrich.WithProperty(AppSource.PropertyName, AppSource.Server) - .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName); + .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) + + // Pinned over the configuration: below Warning, hosting logs the query string a file link's secret travels in, + // and the MCP server the tool results that carry the link. + .MinimumLevel.Override("Microsoft.AspNetCore.Hosting.Diagnostics", LogEventLevel.Warning) + .MinimumLevel.Override("ModelContextProtocol", LogEventLevel.Warning); #pragma warning restore RCS0054 // In code: the Serilog section cannot leave a sink out when no ServerUrl is set. @@ -157,7 +164,7 @@ static branch => branch.UseMiddleware()); // Quiet on the log upload: logging it would be shipped by the next upload. -app.UseRequestLogging(loggedPaths: ["/api"], quietPaths: [ClientLogRoute.Path]); +app.UseRequestLogging(loggedPaths: ["/api", McpRoute.Path], quietPaths: [ClientLogRoute.Path]); if (app.Configuration.GetValue("EvilBrains:EvilCase:Hosting:HttpsRedirection", defaultValue: true)) { diff --git a/src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs b/src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs new file mode 100644 index 000000000..784b6d7b9 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs @@ -0,0 +1,217 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Business.Files; +using EvilBrains.EvilCase.Business.Files.Links; +using EvilBrains.EvilCase.Domain.Files; +using EvilBrains.EvilCase.Domain.Users; +using Microsoft.Extensions.DependencyInjection; + +namespace EvilBrains.EvilCase.Tests.Hosting; + +/// +/// The link store and the file services are fakes; the anonymous endpoint, the tenant it enters and the answers run as in +/// production. +/// +public class FileLinkEndpointTests +{ + private const string Secret = "s3cr3t"; + + private readonly Guid tenantId = Guid.CreateVersion7(); + + private readonly Guid userId = Guid.CreateVersion7(); + + private readonly Guid targetId = Guid.CreateVersion7(); + + private readonly List<(Guid TenantId, Guid UserId, string FileName, string Content)> uploads = []; + + private ConsumedFileLink? link; + + private IFileLinkWriter links = null!; + + private EvilCaseHost host = null!; + + private HttpClient client = null!; + + [OneTimeSetUp] + public void SetUp() + { + this.links = Substitute.For(); + this.links + .ConsumeFileLink(Secret, Arg.Any()) + .Returns(_ => this.link); + + this.host = new EvilCaseHost(configureServices: services => + { + services.AddSingleton(this.links); + + // Records the user, tenant and content the upload runs with. + services.AddScoped(provider => + { + var userContext = provider.GetRequiredService(); + var writer = Substitute.For(); + + writer + .UploadCaseFile(this.targetId, Arg.Any(), Arg.Any()) + .Returns(async call => + { + var upload = call.Arg(); + using var reader = new StreamReader(upload.Content); + + this.uploads.Add((userContext.TenantId, userContext.UserId, upload.FileName, await reader.ReadToEndAsync())); + + return new UploadFileResult { Outcome = UploadFileOutcome.Uploaded, File = File(upload.FileName) }; + }); + + return writer; + }); + + services.AddScoped(provider => + { + var userContext = provider.GetRequiredService(); + var reader = Substitute.For(); + + reader + .OpenFileContent(this.targetId, Arg.Any(), Arg.Any()) + .Returns(_ => userContext.TenantIdOrDefault == this.tenantId + ? new FileDownload { FileName = "zapis.txt", MediaType = "text/plain", Content = new MemoryStream("obsah"u8.ToArray()) } + : null); + + return reader; + }); + }); + + this.client = this.host.CreateClient(); + } + + [OneTimeTearDown] + public void TearDown() + { + this.client.Dispose(); + this.host.Dispose(); + } + + [SetUp] + public void Forget() + { + this.links.ClearReceivedCalls(); + this.uploads.Clear(); + this.link = null; + } + + [Test] + public async Task AnUploadRunsAsTheUserAndTenantOfTheLink() + { + this.link = this.Link(FileLinkKind.CaseUpload); + + using var response = await this.Upload("zapis.txt", "obsah"); + var file = await response.Content.ReadFromJsonAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created)); + Assert.That(file?.FileName, Is.EqualTo("zapis.txt"), "the response returns the stored file"); + Assert.That(this.uploads, Is.EqualTo([(this.tenantId, this.userId, "zapis.txt", "obsah")]), "the anonymous upload runs as the link's user in the link's tenant, named by the link"); + } + } + + [Test] + public async Task AnUnknownOrSpentLinkIsNotFound() + { + using var response = await this.Upload("zapis.txt", "obsah"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(this.uploads, Is.Empty, "nothing is stored without a live link"); + } + } + + [Test] + public async Task ADownloadLinkDoesNotUpload() + { + this.link = this.Link(FileLinkKind.Download); + + using var response = await this.Upload("zapis.txt", "obsah"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound), "a link does only what it was created for"); + Assert.That(this.uploads, Is.Empty); + } + } + + [Test] + public async Task AnInvalidNameIsRefusedBeforeTheLinkIsSpent() + { + this.link = this.Link(FileLinkKind.CaseUpload); + + using var response = await this.Upload(" ", "obsah"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "the upload checks the name as the multipart upload does"); + await this.links.DidNotReceive().ConsumeFileLink(Arg.Any(), Arg.Any()); + } + } + + [Test] + public async Task ADownloadSendsTheContentUncached() + { + this.link = this.Link(FileLinkKind.Download); + + using var response = await this.client.GetAsync(new Uri($"{FileLinkRoute.Path}?{FileLinkRoute.SecretParameter}={Secret}", UriKind.Relative)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), "the download runs in the link's tenant"); + Assert.That(await response.Content.ReadAsStringAsync(), Is.EqualTo("obsah")); + Assert.That(response.Headers.CacheControl?.NoStore, Is.True, "a linked download is never cached"); + } + } + + [Test] + public async Task AnUploadLinkDoesNotDownload() + { + this.link = this.Link(FileLinkKind.CaseUpload); + + using var response = await this.client.GetAsync(new Uri($"{FileLinkRoute.Path}?{FileLinkRoute.SecretParameter}={Secret}", UriKind.Relative)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound), "a link does only what it was created for"); + } + + private static FileListItem File(string fileName) + { + return new FileListItem + { + FileId = Guid.CreateVersion7(), + FileName = fileName, + SizeBytes = 5, + Created = DateTime.UtcNow, + Version = 1, + VersionCount = 1, + }; + } + + private ConsumedFileLink Link(FileLinkKind kind) + { + return new ConsumedFileLink + { + TenantId = this.tenantId, + UserId = this.userId, + ApiTokenId = Guid.CreateVersion7(), + Kind = kind, + TargetId = this.targetId, + }; + } + + private async Task Upload(string fileName, string content) + { + var path = $"{FileLinkRoute.Path}?{FileLinkRoute.SecretParameter}={Secret}&{FileLinkRoute.FileNameParameter}={Uri.EscapeDataString(fileName)}"; + + using var body = new StringContent(content, Encoding.UTF8, "text/plain"); + + return await this.client.PutAsync(new Uri(path, UriKind.Relative), body); + } +} diff --git a/src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs b/src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs new file mode 100644 index 000000000..37661f0d9 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs @@ -0,0 +1,309 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Mcp; +using EvilBrains.EvilCase.Api.Contract.User; +using EvilBrains.EvilCase.Auth; +using EvilBrains.EvilCase.Business.Cases; +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Business.Labels; +using EvilBrains.EvilCase.Data.Entities; +using EvilBrains.EvilCase.Domain.ApiTokens; +using EvilBrains.EvilCase.Domain.Users; +using EvilBrains.EvilCase.Tests.Auth; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace EvilBrains.EvilCase.Tests.Hosting; + +/// +/// The token store and the business services are fakes; the transport, the scheme, the boundary and the tool filters run as +/// in production. +/// +public class McpEndpointTests +{ + private const string ListToolsBody = """{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"""; + + private readonly FakeApiTokenStore store = new(); + + private readonly List tenantsSeen = []; + + private readonly User user = new() + { + Id = Guid.CreateVersion7(), + TenantId = Guid.CreateVersion7(), + Email = "agent@evilcase.test", + PasswordHash = "not-verified-here", + Role = UserRole.User, + }; + + private ILabelWriter labels = null!; + + private EvilCaseHost host = null!; + + private HttpClient client = null!; + + [OneTimeSetUp] + public void SetUp() + { + this.labels = Substitute.For(); + this.labels + .DeleteLabel(Arg.Any(), Arg.Any()) + .Returns(DeleteOutcome.Deleted); + + this.host = new EvilCaseHost(configureServices: services => + { + services.AddSingleton(this.store); + services.AddSingleton(this.labels); + + // Records the tenant the tool runs under; knows no case. + services.AddScoped(provider => + { + var userContext = provider.GetRequiredService(); + var reader = Substitute.For(); + + reader + .ListCases(Arg.Any(), Arg.Any()) + .Returns(_ => + { + this.tenantsSeen.Add(userContext.TenantId); + + return new CaseListResponse { Items = [], TotalCount = 0 }; + }); + + return reader; + }); + }); + + this.client = this.host.CreateClient(); + } + + [OneTimeTearDown] + public void TearDown() + { + this.client.Dispose(); + this.host.Dispose(); + } + + [SetUp] + public void ForgetCalls() + { + this.labels.ClearReceivedCalls(); + this.tenantsSeen.Clear(); + } + + [Test] + public async Task ARequestWithoutATokenIsUnauthorized() + { + using var response = await this.Post(secret: null); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task TheBrowserSessionAloneIsUnauthorized() + { + using var response = await this.Post(secret: null, cookie: TestTokens.TokenFrom(this.host)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "the MCP endpoint accepts an API token only, never the cookie"); + } + + [Test] + public async Task AJwtBearerIsUnauthorized() + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(McpRoute.Path, UriKind.Relative)) { Content = JsonRpc(ListToolsBody) }; + request.Headers.Authorization = TestTokens.BearerFrom(this.host); + + using var response = await this.client.SendAsync(request); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "the MCP endpoint accepts an API token only, never an access token"); + } + + [Test] + public async Task AForeignOriginIsForbidden() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite); + + using var response = await this.Post(secret, origin: "https://evil.example"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), "a browser page of another origin never reaches the MCP endpoint, token or not"); + } + + [Test] + public async Task ARequestWithoutAnOriginPasses() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite); + + using var response = await this.Post(secret); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), "a command-line client sends no Origin"); + } + + [Test] + public async Task AReadOnlyTokenListsOnlyReadTools() + { + await using var mcp = await this.Connect(this.store.Issue(this.user, ApiTokenScope.ReadOnly)); + + var tools = await mcp.ListToolsAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(tools.Select(static tool => tool.Name), Does.Contain("list_cases").And.Contain("create_file_download_link")); + Assert.That(tools.Select(static tool => tool.Name), Has.None.EqualTo("delete_label").And.None.EqualTo("create_file_upload_link")); + Assert.That(tools.Select(static tool => tool.ProtocolTool.Annotations?.ReadOnlyHint), Has.All.True, "a read-only token sees only the tools annotated read-only"); + } + } + + [Test] + public async Task AReadOnlyTokenCannotCallAWriteTool() + { + await using var mcp = await this.Connect(this.store.Issue(this.user, ApiTokenScope.ReadOnly)); + + var result = await mcp.CallToolAsync("delete_label", new Dictionary(StringComparer.Ordinal) { ["labelId"] = Guid.CreateVersion7() }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.IsError, Is.True, "a read-only token calling a hidden write tool gets a tool error"); + await this.labels.DidNotReceive().DeleteLabel(Arg.Any(), Arg.Any()); + } + } + + [Test] + public async Task AReadWriteTokenCallsAWriteTool() + { + await using var mcp = await this.Connect(this.store.Issue(this.user, ApiTokenScope.ReadWrite)); + var labelId = Guid.CreateVersion7(); + + var tools = await mcp.ListToolsAsync(); + var result = await mcp.CallToolAsync("delete_label", new Dictionary(StringComparer.Ordinal) { ["labelId"] = labelId }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(tools.Select(static tool => tool.Name), Does.Contain("delete_label")); + Assert.That(result.IsError, Is.Not.True); + await this.labels.Received(1).DeleteLabel(labelId, Arg.Any()); + } + } + + [Test] + public async Task AToolRunsUnderTheTenantOfTheTokensUser() + { + await using var mcp = await this.Connect(this.store.Issue(this.user, ApiTokenScope.ReadOnly)); + + var result = await mcp.CallToolAsync( + "list_cases", + new Dictionary(StringComparer.Ordinal) { ["request"] = new Dictionary(StringComparer.Ordinal) { ["take"] = 10 } }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.IsError, Is.Not.True); + Assert.That(this.tenantsSeen, Is.EqualTo([this.user.TenantId]), "a tool acts as the token's user, tenant included"); + } + } + + [Test] + public async Task AnInvalidArgumentIsAToolErrorWithTheValidationMessage() + { + await using var mcp = await this.Connect(this.store.Issue(this.user, ApiTokenScope.ReadOnly)); + + var result = await mcp.CallToolAsync( + "list_cases", + new Dictionary(StringComparer.Ordinal) { ["request"] = new Dictionary(StringComparer.Ordinal) { ["take"] = 101 } }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.IsError, Is.True); + Assert.That(Text(result), Does.Contain("Take"), "a list takes the paging REST takes, Take at most 100"); + Assert.That(this.tenantsSeen, Is.Empty, "an invalid request never reaches the business service"); + } + } + + [Test] + public async Task AnUnknownEntityIsAToolErrorWithTheProblemTitle() + { + await using var mcp = await this.Connect(this.store.Issue(this.user, ApiTokenScope.ReadOnly)); + + var result = await mcp.CallToolAsync("get_case", new Dictionary(StringComparer.Ordinal) { ["caseId"] = Guid.CreateVersion7() }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.IsError, Is.True); + Assert.That(Text(result), Does.Contain(CaseProblems.NotFound), "a not-found answers with the Problem Details title REST answers with"); + } + } + + [Test] + public async Task EveryToolIsDescribedAndAnnotated() + { + await using var mcp = await this.Connect(this.store.Issue(this.user, ApiTokenScope.ReadWrite)); + + var tools = await mcp.ListToolsAsync(); + + using (Assert.EnterMultipleScope()) + { + foreach (var tool in tools) + { + var annotations = tool.ProtocolTool.Annotations; + var reads = tool.Name.StartsWith("list_", StringComparison.Ordinal) + || tool.Name.StartsWith("get_", StringComparison.Ordinal) + || tool.Name.StartsWith("read_", StringComparison.Ordinal) + || tool.Name.StartsWith("count_", StringComparison.Ordinal) + || tool.Name.StartsWith("verify_", StringComparison.Ordinal) + || string.Equals(tool.Name, "search", StringComparison.Ordinal) + || string.Equals(tool.Name, "create_file_download_link", StringComparison.Ordinal); + + Assert.That(tool.Description, Is.Not.Empty, $"{tool.Name}: every tool says what it does"); + Assert.That(annotations?.ReadOnlyHint == true, Is.EqualTo(reads), $"{tool.Name}: a read tool, and only a read tool, carries readOnlyHint"); + + if (tool.Name.StartsWith("delete_", StringComparison.Ordinal)) + Assert.That(annotations?.DestructiveHint, Is.True, $"{tool.Name}: a delete tool carries destructiveHint"); + } + } + } + + private static StringContent JsonRpc(string body) + { + return new StringContent(body, Encoding.UTF8, "application/json"); + } + + private static string Text(CallToolResult result) + { + return string.Concat(result.Content.OfType().Select(static block => block.Text)); + } + + private async Task Post(string? secret, string? cookie = null, string? origin = null) + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(McpRoute.Path, UriKind.Relative)) { Content = JsonRpc(ListToolsBody) }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + if (secret is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secret); + + if (cookie is not null) + request.Headers.Add("Cookie", $"{AccessCookie.Name}={cookie}"); + + if (origin is not null) + request.Headers.Add("Origin", origin); + + return await this.client.SendAsync(request); + } + + private async Task Connect(string secret) + { + var transport = new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri(this.client.BaseAddress!, McpRoute.Path), + TransportMode = HttpTransportMode.StreamableHttp, + AdditionalHeaders = new Dictionary(StringComparer.Ordinal) { ["Authorization"] = $"Bearer {secret}" }, + }, + this.client, + loggerFactory: null, + ownsHttpClient: false); + + return await McpClient.CreateAsync(transport); + } +} diff --git a/src/Tests/EvilCase.Tests/Mcp/McpToolTenancyTests.cs b/src/Tests/EvilCase.Tests/Mcp/McpToolTenancyTests.cs new file mode 100644 index 000000000..2ea790d30 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Mcp/McpToolTenancyTests.cs @@ -0,0 +1,67 @@ +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Api.Mcp; +using EvilBrains.EvilCase.Business.Cases; +using EvilBrains.EvilCase.Business.Files.Text; +using EvilBrains.EvilCase.Tests.Data; +using EvilBrains.EvilCase.Tests.Seeding; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol; + +namespace EvilBrains.EvilCase.Tests.Mcp; + +/// +/// The tools over the real business services: what the tenant filter keeps out of REST stays out of a tool. +/// +public class McpToolTenancyTests : TenantFixture +{ + private static readonly DateOnly Day = new(2026, 10, 30); + + [Test] + public async Task ACaseOfAnotherTenantIsNotFoundThroughATool() + { + await using var other = await TestTenant.Create(); + var otherCase = await other.AddCase(Day, title: "Cizí spis"); + var reader = new CaseReader(new FixedDbSession(this.Tenant.Context)); + + Assert.That( + async () => await CaseTools.GetCase(reader, otherCase.Id, CancellationToken.None), + Throws.InstanceOf().With.Message.Contains(CaseProblems.NotFound), + "a case id alone never reaches another tenant's case through a tool"); + } + + [Test] + public async Task TheTextOfAFileOfAnotherTenantIsNotFoundThroughATool() + { + await using var other = await TestTenant.Create(); + var otherCase = await other.AddCase(Day); + var otherFile = await other.AddCaseFile(otherCase, "zapis.txt"); + var blobs = new FakeFileBlobStore(); + blobs.WrittenByPath[otherFile.Versions.Single().StoragePath] = "cizí obsah"; + var reader = new FileTextReader(new FixedDbSession(this.Tenant.Context), blobs, NullLogger.Instance); + + Assert.That( + async () => await FileTools.ReadFileText(reader, otherFile.Id, version: null, page: 1, CancellationToken.None), + Throws.InstanceOf().With.Message.Contains(FileProblems.NotFound), + "a file id alone never reaches another tenant's content through a tool"); + } + + [Test] + public async Task TheTextOfAFileOfTheTenantIsRead() + { + var @case = await this.Tenant.AddCase(Day); + var file = await this.Tenant.AddCaseFile(@case, "zapis.txt"); + var blobs = new FakeFileBlobStore(); + blobs.WrittenByPath[file.Versions.Single().StoragePath] = "vlastní obsah"; + var reader = new FileTextReader(new FixedDbSession(this.Tenant.Context), blobs, NullLogger.Instance); + + var page = await FileTools.ReadFileText(reader, file.Id, version: null, page: 1, CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(page.Text, Is.EqualTo("vlastní obsah")); + Assert.That(page.TotalLength, Is.EqualTo("vlastní obsah".Length)); + Assert.That(page.PageCount, Is.EqualTo(1)); + } + } +} From 47755bc2545c4581e3f74df84703797bcabea86a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:51:18 +0000 Subject: [PATCH 3/5] Answer a file link use with an outcome Consuming a link is a business write that can fail, so it answers with an outcome enum and the action maps every member. The link's token id had no reader and is dropped from the consumed link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- .../Controllers/FileLinksController.cs | 17 +++++++++++++-- .../Files/Links/ConsumedFileLink.cs | 2 -- .../Files/Links/FileLinkConsumeOutcome.cs | 11 ++++++++++ .../Files/Links/FileLinkConsumeResult.cs | 11 ++++++++++ .../Files/Links/FileLinkWriter.cs | 21 +++++++++++-------- .../Files/Links/IFileLinkWriter.cs | 4 ++-- .../Files/FileLinkWriterTests.cs | 17 ++++++++------- .../Hosting/FileLinkEndpointTests.cs | 5 +++-- 8 files changed, 63 insertions(+), 25 deletions(-) create mode 100644 src/Business/EvilCase.Business/Files/Links/FileLinkConsumeOutcome.cs create mode 100644 src/Business/EvilCase.Business/Files/Links/FileLinkConsumeResult.cs diff --git a/src/Api/EvilCase.Api/Controllers/FileLinksController.cs b/src/Api/EvilCase.Api/Controllers/FileLinksController.cs index 85a078634..03419c395 100644 --- a/src/Api/EvilCase.Api/Controllers/FileLinksController.cs +++ b/src/Api/EvilCase.Api/Controllers/FileLinksController.cs @@ -34,7 +34,7 @@ public async Task DownloadLinkedFile( [FromQuery(Name = FileLinkRoute.SecretParameter)] string secret, CancellationToken token) { - var link = await links.ConsumeFileLink(secret, token); + var link = Consumed(await links.ConsumeFileLink(secret, token)); if (link is not { Kind: FileLinkKind.Download }) return this.LinkNotFoundProblem(); @@ -72,7 +72,7 @@ public async Task> UploadLinkedFile( if (this.InvalidUploadMetadataProblem(lastSegment, mediaType) is { } problem) return problem; - var link = await links.ConsumeFileLink(secret, token); + var link = Consumed(await links.ConsumeFileLink(secret, token)); if (link is null or { Kind: FileLinkKind.Download }) return this.LinkNotFoundProblem(); @@ -91,6 +91,19 @@ public async Task> UploadLinkedFile( }; } + /// + /// Null where no live link answers the secret. + /// + private static ConsumedFileLink? Consumed(FileLinkConsumeResult result) + { + return result.Outcome switch + { + FileLinkConsumeOutcome.Consumed => result.Link, + FileLinkConsumeOutcome.NotFound => null, + _ => throw new UnreachableException(), + }; + } + private ActionResult Answer(UploadFileResult result, string ownerNotFound) { return result.Outcome switch diff --git a/src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs b/src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs index 3b160181e..6090b8d31 100644 --- a/src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs +++ b/src/Business/EvilCase.Business/Files/Links/ConsumedFileLink.cs @@ -11,8 +11,6 @@ public sealed record ConsumedFileLink public required Guid UserId { get; init; } - public required Guid ApiTokenId { get; init; } - public required FileLinkKind Kind { get; init; } public required Guid TargetId { get; init; } diff --git a/src/Business/EvilCase.Business/Files/Links/FileLinkConsumeOutcome.cs b/src/Business/EvilCase.Business/Files/Links/FileLinkConsumeOutcome.cs new file mode 100644 index 000000000..a13cada48 --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/FileLinkConsumeOutcome.cs @@ -0,0 +1,11 @@ +namespace EvilBrains.EvilCase.Business.Files.Links; + +public enum FileLinkConsumeOutcome +{ + Consumed = 0, + + /// + /// The link is unknown, used or expired. + /// + NotFound = 1, +} diff --git a/src/Business/EvilCase.Business/Files/Links/FileLinkConsumeResult.cs b/src/Business/EvilCase.Business/Files/Links/FileLinkConsumeResult.cs new file mode 100644 index 000000000..9821cdaaf --- /dev/null +++ b/src/Business/EvilCase.Business/Files/Links/FileLinkConsumeResult.cs @@ -0,0 +1,11 @@ +namespace EvilBrains.EvilCase.Business.Files.Links; + +public sealed record FileLinkConsumeResult +{ + public required FileLinkConsumeOutcome Outcome { get; init; } + + /// + /// Set where the outcome is . + /// + public ConsumedFileLink? Link { get; init; } +} diff --git a/src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs b/src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs index d0302fa03..c5115e4b4 100644 --- a/src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs +++ b/src/Business/EvilCase.Business/Files/Links/FileLinkWriter.cs @@ -61,7 +61,7 @@ public async Task CreateFileLink(FileLinkKind kind, Guid t return new FileLinkCreateResult { Outcome = FileLinkCreateOutcome.Created, Secret = secret, Expires = expires }; } - public async Task ConsumeFileLink(string secret, CancellationToken token) + public async Task ConsumeFileLink(string secret, CancellationToken token) { var now = timeProvider.GetUtcNow().UtcDateTime; @@ -72,18 +72,21 @@ public async Task CreateFileLink(FileLinkKind kind, Guid t .ToListAsync(token); if (consumed.SingleOrDefault() is not { } link || link.Expires <= now) - return null; + return new FileLinkConsumeResult { Outcome = FileLinkConsumeOutcome.NotFound }; logger.LogInformation("File link {FileLinkId} ({FileLinkKind}) was used for {TargetId}", link.Id, link.Kind, link.TargetId); - return new ConsumedFileLink + return new FileLinkConsumeResult { - TenantId = link.TenantId, - UserId = link.UserId, - ApiTokenId = link.ApiTokenId, - Kind = link.Kind, - TargetId = link.TargetId, - Version = link.Version, + Outcome = FileLinkConsumeOutcome.Consumed, + Link = new ConsumedFileLink + { + TenantId = link.TenantId, + UserId = link.UserId, + Kind = link.Kind, + TargetId = link.TargetId, + Version = link.Version, + }, }; } diff --git a/src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs b/src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs index f249c5bae..4b73b3d7b 100644 --- a/src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs +++ b/src/Business/EvilCase.Business/Files/Links/IFileLinkWriter.cs @@ -11,9 +11,9 @@ public interface IFileLinkWriter public Task CreateFileLink(FileLinkKind kind, Guid targetId, int? version, Guid apiTokenId, CancellationToken token); /// - /// The link the secret names, deleted by the same statement that reads it; null where it is unknown, used or expired. + /// The link the secret names, deleted by the same statement that reads it. /// - public Task ConsumeFileLink(string secret, CancellationToken token); + public Task ConsumeFileLink(string secret, CancellationToken token); public Task DeleteExpiredFileLinks(CancellationToken token); } diff --git a/src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs b/src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs index 3e4180493..fad0faa42 100644 --- a/src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs +++ b/src/Tests/EvilCase.Tests/Files/FileLinkWriterTests.cs @@ -55,11 +55,12 @@ public async Task ALinkIsSpentByItsFirstUse() { Assert.That(created.Outcome, Is.EqualTo(FileLinkCreateOutcome.Created)); Assert.That(created.Expires, Is.EqualTo(this.time.UtcNow.AddMinutes(10)), "a link is valid ten minutes"); - Assert.That(first?.TenantId, Is.EqualTo(this.Tenant.UserContext.TenantId), "the use runs in the tenant that created the link, found before any tenant is known"); - Assert.That(first?.UserId, Is.EqualTo(this.Tenant.UserId), "the use runs as the user that created the link"); - Assert.That(first?.Kind, Is.EqualTo(FileLinkKind.CaseUpload)); - Assert.That(first?.TargetId, Is.EqualTo(@case.Id)); - Assert.That(second, Is.Null, "a link is spent by its first use"); + Assert.That(first.Outcome, Is.EqualTo(FileLinkConsumeOutcome.Consumed)); + Assert.That(first.Link?.TenantId, Is.EqualTo(this.Tenant.UserContext.TenantId), "the use runs in the tenant that created the link, found before any tenant is known"); + Assert.That(first.Link?.UserId, Is.EqualTo(this.Tenant.UserId), "the use runs as the user that created the link"); + Assert.That(first.Link?.Kind, Is.EqualTo(FileLinkKind.CaseUpload)); + Assert.That(first.Link?.TargetId, Is.EqualTo(@case.Id)); + Assert.That(second.Outcome, Is.EqualTo(FileLinkConsumeOutcome.NotFound), "a link is spent by its first use"); } } @@ -76,7 +77,7 @@ public async Task TwoUsesAtOnceSpendTheLinkOnce() this.WriterOn(one).ConsumeFileLink(created.Secret!, CancellationToken.None), this.WriterOn(two).ConsumeFileLink(created.Secret!, CancellationToken.None)); - Assert.That(uses.Count(static use => use is not null), Is.EqualTo(1), "one statement finds and deletes the link, so only one of two concurrent uses gets it"); + Assert.That(uses.Count(static use => use.Outcome == FileLinkConsumeOutcome.Consumed), Is.EqualTo(1), "one statement finds and deletes the link, so only one of two concurrent uses gets it"); } [Test] @@ -88,7 +89,7 @@ public async Task AnExpiredLinkIsRefused() this.time.Advance(TimeSpan.FromMinutes(10)); var used = await this.writer.ConsumeFileLink(created.Secret!, CancellationToken.None); - Assert.That(used, Is.Null, "a link is never usable once expired, removed or not"); + Assert.That(used.Outcome, Is.EqualTo(FileLinkConsumeOutcome.NotFound), "a link is never usable once expired, removed or not"); } [Test] @@ -171,7 +172,7 @@ await this.Tenant.Context.ApiTokens var used = await this.writer.ConsumeFileLink(created.Secret!, CancellationToken.None); - Assert.That(used, Is.Null, "a revoked token's links stop working with it"); + Assert.That(used.Outcome, Is.EqualTo(FileLinkConsumeOutcome.NotFound), "a revoked token's links stop working with it"); } private FileLinkWriter WriterOn(ApplicationDbContext context) diff --git a/src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs b/src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs index 784b6d7b9..fddd05f23 100644 --- a/src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs +++ b/src/Tests/EvilCase.Tests/Hosting/FileLinkEndpointTests.cs @@ -40,7 +40,9 @@ public void SetUp() this.links = Substitute.For(); this.links .ConsumeFileLink(Secret, Arg.Any()) - .Returns(_ => this.link); + .Returns(_ => this.link is null + ? new FileLinkConsumeResult { Outcome = FileLinkConsumeOutcome.NotFound } + : new FileLinkConsumeResult { Outcome = FileLinkConsumeOutcome.Consumed, Link = this.link }); this.host = new EvilCaseHost(configureServices: services => { @@ -200,7 +202,6 @@ private ConsumedFileLink Link(FileLinkKind kind) { TenantId = this.tenantId, UserId = this.userId, - ApiTokenId = Guid.CreateVersion7(), Kind = kind, TargetId = this.targetId, }; From a0d268aa2470ff35131d14afdbaf1274e319732c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:51:22 +0000 Subject: [PATCH 4/5] Check that every MCP tool parameter is described SDD-022 requires an English description on every tool and every parameter; the convention test covered the tools only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs b/src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs index 37661f0d9..80f4985a4 100644 --- a/src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs +++ b/src/Tests/EvilCase.Tests/Hosting/McpEndpointTests.cs @@ -255,6 +255,16 @@ public async Task EveryToolIsDescribedAndAnnotated() || string.Equals(tool.Name, "create_file_download_link", StringComparison.Ordinal); Assert.That(tool.Description, Is.Not.Empty, $"{tool.Name}: every tool says what it does"); + + if (tool.ProtocolTool.InputSchema.TryGetProperty("properties", out var parameters)) + { + foreach (var parameter in parameters.EnumerateObject()) + { + var described = parameter.Value.TryGetProperty("description", out var description) && !string.IsNullOrWhiteSpace(description.GetString()); + Assert.That(described, Is.True, $"{tool.Name}.{parameter.Name}: every parameter says what it is"); + } + } + Assert.That(annotations?.ReadOnlyHint == true, Is.EqualTo(reads), $"{tool.Name}: a read tool, and only a read tool, carries readOnlyHint"); if (tool.Name.StartsWith("delete_", StringComparison.Ordinal)) From b81fb74756ce492ace332387a8ae52a9a36a0fd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 21:15:09 +0000 Subject: [PATCH 5/5] Name the MCP server at /mcp in the hosting instructions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- .claude/rules/api.md | 2 +- CLAUDE.md | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.claude/rules/api.md b/.claude/rules/api.md index ef9e35fdc..609968337 100644 --- a/.claude/rules/api.md +++ b/.claude/rules/api.md @@ -7,7 +7,7 @@ paths: # API and hosting `EvilCase.Host` is the composition root. One process serves everything: `/api/**` is the API, -every other path returns the frontend's `index.html`. +`/mcp` the MCP server (SDD-023), every other path returns the frontend's `index.html`. - Same-origin only. No CORS anywhere. - Security headers including the CSP come from `SecurityHeadersMiddleware`. Changing an inline diff --git a/CLAUDE.md b/CLAUDE.md index cf919e4b7..dbd1f6224 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,14 +10,14 @@ Node.js 22+ is a build prerequisite: the App build runs `npm ci` and generates t ## Solution map All code lives in `src/` (solution `EvilCase.slnx`). `EvilCase.Host` is the only runnable -project: one process serves `/api/**` and returns the frontend on every other path. -`Api/EvilCase.Api` is the API as a library, `Api.Client` the typed client generated from the -controllers, `Api.Contract` the shared DTOs. `App/EvilCase.App` is the Blazor WebAssembly -frontend. `Business/EvilCase.Business` holds the business logic, `Business/EvilCase.Domain` the -dependency-free shared kernel, `Common/EvilCase.Auth` authentication, `Common/EvilCase.Files` file -storage on disk. `Data/EvilCase.Data` is the EF Core model (PostgreSQL) and the way to the -database; `Data.Migrations` its migrations. `Tests/EvilCase.Tests` holds the application tests -(NUnit), `Utils/EvilBrains.*` the shared libraries and their tests. +project: one process serves `/api/**`, the MCP server at `/mcp` (SDD-023) and the frontend on +every other path. `Api/EvilCase.Api` is the API as a library, `Api.Client` the typed client +generated from the controllers, `Api.Contract` the shared DTOs. `App/EvilCase.App` is the Blazor +WebAssembly frontend. `Business/EvilCase.Business` holds the business logic, +`Business/EvilCase.Domain` the dependency-free shared kernel, `Common/EvilCase.Auth` authentication, +`Common/EvilCase.Files` file storage on disk. `Data/EvilCase.Data` is the EF Core model (PostgreSQL) +and the way to the database; `Data.Migrations` its migrations. `Tests/EvilCase.Tests` holds the +application tests (NUnit), `Utils/EvilBrains.*` the shared libraries and their tests. The rules are in `.claude/rules/` and load themselves — always, or when a file in their area is read. Detail sits next to what it describes: `docs/product/vision.md`, `docs/sdd/`,