diff --git a/docs/superpowers/plans/2026-07-23-buildflow-api-security-and-ownership.md b/docs/superpowers/plans/2026-07-23-buildflow-api-security-and-ownership.md new file mode 100644 index 0000000..6d8113f --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-buildflow-api-security-and-ownership.md @@ -0,0 +1,2214 @@ +# BuildFlow API Security And Ownership Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 关闭 BuildFlow API 的固定管理员口令、Obsidian 任意目录读取、RAG 会话越权访问和 Consul 配置值日志泄露,并用可重复测试建立绿色安全基线。 + +**Architecture:** HTTP Endpoint 只保留路由、权限和 SSE 编码;对象所有权在 RAG Application handler 中通过 `ICurrentUserContext` 与数据库联合条件执行。平台管理员初始化由显式配置驱动,Obsidian 文件系统访问由 Infrastructure 扫描器统一实施根目录、符号链接和容量边界,流式聊天在本批直接迁移到 MediatR `IStreamRequest`,不保留 Endpoint 直连 `RagDbContext` 的过渡实现。 + +**Tech Stack:** .NET 10、EF Core、MediatR、FastEndpoints、FluentValidation、xUnit + +--- + +## File Map + +### Baseline + +- Modify: `tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs` — 用 `AddOptions()` 注册生产代码需要的 `IOptionsMonitor`。 + +### Bootstrap Admin + +- Create: `src/modules/platform/Domain/Services/PasswordPolicy.cs` — 注册和管理员初始化共享的密码强度单一事实源。 +- Create: `src/modules/platform/Infrastructure/Configuration/BootstrapAdminOptions.cs` — 映射 `Platform:BootstrapAdmin`。 +- Create: `src/modules/platform/Infrastructure/Persistence/BootstrapAdminSeeder.cs` — 只在显式启用且不存在管理员时创建或授权管理员。 +- Modify: `src/modules/platform/Application/Auth/Validators/RegisterCommandValidator.cs` — 使用共享密码策略。 +- Modify: `src/BuildFlow.Host/Endpoints/Platform/Auth/RegisterRequestValidator.cs` — 与 Application 注册校验保持一致。 +- Modify: `src/modules/platform/Infrastructure/Persistence/SeedData.cs` — 删除固定 `admin123` 管理员种子块。 +- Modify: `src/BuildFlow.Host/BuildFlowHostModule.cs` — 绑定管理员初始化配置。 +- Modify: `src/BuildFlow.Host/Configuration/DatabaseInitializer.cs` — 在平台基础种子后执行显式管理员初始化并仅记录创建或跳过。 +- Modify: `src/BuildFlow.Host/appsettings.json` — 默认关闭管理员初始化,不提供真实密码。 +- Modify: `src/BuildFlow.Host/appsettings.Secrets.example.json` — 展示 Secrets 配置形状。 +- Create: `tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs` — 固定共享密码规则。 +- Create: `tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs` — 覆盖关闭、缺密码、弱密码、首次创建和重复启动。 + +### Obsidian Boundary + +- Create: `src/modules/rag/Infrastructure/Obsidian/ObsidianOptions.cs` — 映射允许根目录和容量上限。 +- Create: `src/modules/rag/Infrastructure/Obsidian/ObsidianVaultFile.cs` — 扫描器输出的不可变文件元数据。 +- Create: `src/modules/rag/Infrastructure/Obsidian/ObsidianVaultScanner.cs` — 流式枚举、物理路径校验和流式 SHA256。 +- Modify: `src/modules/rag/Infrastructure/RAGInfrastructureModule.cs` — 绑定并注册 Obsidian 配置与扫描器。 +- Modify: `src/BuildFlow.Host/appsettings.json` — 增加默认禁用访问的 `Rag:Obsidian` 配置。 +- Modify: `src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs` — 删除匿名访问并声明 `knowledge:write`。 +- Modify: `src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs` — 校验知识库所有权并消费流式扫描结果。 +- Create: `tests/BuildFlow.Rag.Tests/ObsidianVaultScannerTests.cs` — 覆盖根目录、符号链接、数量、大小、累计大小和受控内存。 +- Create: `tests/BuildFlow.Rag.Tests/ObsidianVaultScannerCollection.cs` — 隔离托管内存测量,避免测试并发噪声。 +- Create: `tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs` — 覆盖非所有者和合法增量同步。 +- Create: `tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs` — 固定 Endpoint 权限与流式 CQRS 边界。 + +### Conversation Ownership + +- Modify: `src/modules/rag/Application/Chat/Handlers/GetConversationDetailQueryHandler.cs` — 详情按当前用户联合查询。 +- Modify: `src/modules/rag/Application/Chat/Handlers/UpdateConversationCommandHandler.cs` — 更新按当前用户联合查询。 +- Modify: `src/modules/rag/Application/Chat/Handlers/DeleteConversationCommandHandler.cs` — 删除按当前用户联合查询。 +- Modify: `src/modules/rag/Application/Chat/Handlers/SendMessageCommandHandler.cs` — 普通发送按当前用户联合查询。 +- Create: `src/modules/rag/Application/Chat/Dtos/ChatStreamEvent.cs` — Application 到 Host 的流式事件契约。 +- Create: `src/modules/rag/Application/Chat/Commands/StreamMessageCommand.cs` — 普通流式发送请求。 +- Create: `src/modules/rag/Application/Chat/Commands/EditMessageStreamCommand.cs` — 编辑后重新生成请求。 +- Create: `src/modules/rag/Application/Chat/Handlers/StreamMessageCommandHandler.cs` — 流式发送业务、持久化和所有权。 +- Create: `src/modules/rag/Application/Chat/Handlers/EditMessageStreamCommandHandler.cs` — 编辑重生成业务、持久化和所有权。 +- Modify: `src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs` — 只发送 MediatR 流并编码 SSE。 +- Modify: `src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs` — 只发送 MediatR 流并编码 SSE。 +- Modify: `tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs` — 更新构造函数并覆盖发送越权。 +- Modify: `tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs` — 覆盖详情、更新和删除越权。 +- Create: `tests/BuildFlow.Rag.Tests/ChatStreamOwnershipTests.cs` — 覆盖两个流式操作的所有权和事件协议。 + +### Secret Logging + +- Modify: `src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs` — 删除配置 key/value 示例日志。 +- Create: `tests/BuildFlow.Architecture.Tests/ConfigurationSecurityTests.cs` — 静态禁止配置值日志表达式。 + +--- + +### Task 0: Establish A Green Test Baseline + +**Files:** +- Modify: `tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs:24-37` +- Modify: `tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs:78-91` + +- [ ] **Step 1: Run the known failing RAG tests** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~AiModelRuntimeConfigResolverTests +``` + +Expected: FAIL because `AiModelRuntimeConfigResolver` requests `IOptionsMonitor` but the test container only contains `IOptions`. + +- [ ] **Step 2: Replace both legacy options registrations** + +In each test, replace `services.AddSingleton(Options.Create(new AiOptions { ... }))` with this registration, preserving the values already used by that test: + +```csharp +services.AddOptions() + .Configure(options => + { + options.BaseUrl = "http://localhost:1234/v1"; + options.ApiKey = "lm-studio"; + options.ChatModel = "fallback-chat"; + options.EmbeddingModel = "fallback-embedding"; + options.DefaultInstructions = + "你是 BuildFlow AI 助手。没有知识库上下文时按普通对话直接回答。"; + }); +``` + +Use this exact block in the embedding test: + +```csharp +services.AddOptions() + .Configure(options => + { + options.BaseUrl = "https://fallback.example/v1"; + options.ApiKey = "fallback-key"; + options.ChatModel = "fallback-chat"; + options.EmbeddingModel = "fallback-embedding"; + options.DefaultInstructions = "fallback instructions"; + }); +``` + +Remove the now-unused `using Microsoft.Extensions.Options;` directive. + +- [ ] **Step 3: Verify the focused tests pass** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~AiModelRuntimeConfigResolverTests +``` + +Expected: PASS, 2 tests passed, 0 failed. + +- [ ] **Step 4: Establish the full pre-change baseline** + +Run: + +```bash +dotnet test BuildFlow.slnx --no-restore +``` + +Expected: PASS with 0 failed. Record total, failed, and skipped counts in the execution handoff before modifying production code. + +- [ ] **Step 5: Commit the baseline repair** + +```bash +git add tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs +git commit -m "test: restore RAG options monitor baseline" +``` + +### Task 1: Replace The Fixed Administrator Password With Explicit Bootstrap Configuration + +**Files:** +- Create: `src/modules/platform/Domain/Services/PasswordPolicy.cs` +- Create: `src/modules/platform/Infrastructure/Configuration/BootstrapAdminOptions.cs` +- Create: `src/modules/platform/Infrastructure/Persistence/BootstrapAdminSeeder.cs` +- Modify: `src/modules/platform/Application/Auth/Validators/RegisterCommandValidator.cs` +- Modify: `src/BuildFlow.Host/Endpoints/Platform/Auth/RegisterRequestValidator.cs` +- Modify: `src/modules/platform/Infrastructure/Persistence/SeedData.cs:54-82` +- Modify: `src/BuildFlow.Host/BuildFlowHostModule.cs:60-130` +- Modify: `src/BuildFlow.Host/Configuration/DatabaseInitializer.cs:73-92` +- Modify: `src/BuildFlow.Host/appsettings.json` +- Modify: `src/BuildFlow.Host/appsettings.Secrets.example.json` +- Create: `tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs` +- Create: `tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs` + +- [ ] **Step 1: Write failing password policy and bootstrap tests** + +Create `PasswordPolicyTests.cs` with the exact accepted and rejected cases: + +```csharp +using BuildFlow.Platform.Domain.Services; +using Xunit; + +namespace BuildFlow.Platform.Tests.Auth; + +public class PasswordPolicyTests +{ + [Theory] + [InlineData("")] + [InlineData("short1A")] + [InlineData("alllowercase1")] + [InlineData("ALLUPPERCASE1")] + [InlineData("NoDigitsHere")] + public void InvalidPasswordsShouldReturnAnError(string password) + { + Assert.NotNull(PasswordPolicy.GetValidationError(password)); + } + + [Fact] + public void StrongPasswordShouldPass() + { + Assert.Null(PasswordPolicy.GetValidationError("BootstrapAdmin1")); + } +} +``` + +Create `BootstrapAdminSeederTests.cs` with a shared option factory and these five facts: + +```csharp +using BuildFlow.Platform.Domain.Entities.Role; +using BuildFlow.Platform.Infrastructure.Configuration; +using BuildFlow.Platform.Infrastructure.Persistence; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BuildFlow.Platform.Tests.Auth; + +public class BootstrapAdminSeederTests +{ + private static BootstrapAdminOptions ValidOptions(string password = "BootstrapAdmin1") => new() + { + Enabled = true, + Username = "bootstrap-admin", + Email = "bootstrap-admin@test.local", + Password = password, + }; + + private static async Task SeedAdminRoleAsync(PlatformDbContext db) + { + db.Roles.Add(PlatformTestDb.WithAudit(new RoleEntity + { + Id = BootstrapAdminSeeder.AdminRoleId, + Name = "Admin", + Description = "管理员", + })); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task DisabledShouldNotCreateAUser() + { + await using var db = PlatformTestDb.CreateDbContext(); + var created = await BootstrapAdminSeeder.SeedAsync(db, new BootstrapAdminOptions()); + created.Should().BeFalse(); + db.Users.Should().BeEmpty(); + } + + [Fact] + public async Task EnabledWithoutPasswordShouldFailClearly() + { + await using var db = PlatformTestDb.CreateDbContext(); + var options = ValidOptions(""); + var act = () => BootstrapAdminSeeder.SeedAsync(db, options); + await act.Should().ThrowAsync() + .WithMessage("*密码不能为空*"); + } + + [Fact] + public async Task WeakPasswordShouldFailClearly() + { + await using var db = PlatformTestDb.CreateDbContext(); + var act = () => BootstrapAdminSeeder.SeedAsync(db, ValidOptions("weakpass")); + await act.Should().ThrowAsync() + .WithMessage("*大写字母*"); + } + + [Fact] + public async Task FirstRunShouldCreateAnAdminWithTheConfiguredPassword() + { + await using var db = PlatformTestDb.CreateDbContext(); + await SeedAdminRoleAsync(db); + var options = ValidOptions(); + + var created = await BootstrapAdminSeeder.SeedAsync(db, options); + + created.Should().BeTrue(); + var user = await db.Users.SingleAsync(); + user.Username.Should().Be(options.Username); + BCrypt.Net.BCrypt.Verify(options.Password, user.PasswordHash).Should().BeTrue(); + (await db.UserRoles.CountAsync(x => + x.UserId == user.Id && x.RoleId == BootstrapAdminSeeder.AdminRoleId)) + .Should().Be(1); + } + + [Fact] + public async Task RepeatedRunShouldNotResetTheExistingAdminPassword() + { + await using var db = PlatformTestDb.CreateDbContext(); + await SeedAdminRoleAsync(db); + await BootstrapAdminSeeder.SeedAsync(db, ValidOptions("BootstrapAdmin1")); + var originalHash = await db.Users.Select(x => x.PasswordHash).SingleAsync(); + + var created = await BootstrapAdminSeeder.SeedAsync(db, ValidOptions("DifferentAdmin2")); + + created.Should().BeFalse(); + (await db.Users.Select(x => x.PasswordHash).SingleAsync()).Should().Be(originalHash); + BCrypt.Net.BCrypt.Verify("BootstrapAdmin1", originalHash).Should().BeTrue(); + } +} +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +Run: + +```bash +dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore --filter "FullyQualifiedName~PasswordPolicyTests|FullyQualifiedName~BootstrapAdminSeederTests" +``` + +Expected: FAIL to compile because `PasswordPolicy`, `BootstrapAdminOptions`, and `BootstrapAdminSeeder` do not exist. + +- [ ] **Step 3: Add the shared password policy** + +Create `PasswordPolicy.cs`: + +```csharp +namespace BuildFlow.Platform.Domain.Services; + +/// 平台账号统一密码强度规则。 +public static class PasswordPolicy +{ + public const int MinimumLength = 8; + public const int MaximumLength = 100; + + public static string? GetValidationError(string? password) + { + if (string.IsNullOrEmpty(password)) return "密码不能为空"; + if (password.Length < MinimumLength) return "密码至少 8 个字符"; + if (password.Length > MaximumLength) return "密码最多 100 个字符"; + if (!password.Any(c => c is >= 'A' and <= 'Z')) return "密码必须包含至少一个大写字母"; + if (!password.Any(c => c is >= 'a' and <= 'z')) return "密码必须包含至少一个小写字母"; + if (!password.Any(c => c is >= '0' and <= '9')) return "密码必须包含至少一个数字"; + return null; + } +} +``` + +Replace the password rules in both registration validators with the same custom rule: + +```csharp +RuleFor(x => x.Password) + .Custom((password, context) => + { + var error = PasswordPolicy.GetValidationError(password); + if (error is not null) context.AddFailure(error); + }); +``` + +Add `using BuildFlow.Platform.Domain.Services;` to both files. + +- [ ] **Step 4: Add explicit bootstrap options and seeder** + +Create `BootstrapAdminOptions.cs`: + +```csharp +namespace BuildFlow.Platform.Infrastructure.Configuration; + +/// 首次部署管理员初始化配置。 +public sealed class BootstrapAdminOptions +{ + public const string SectionName = "Platform:BootstrapAdmin"; + + public bool Enabled { get; set; } + public string Username { get; set; } = "admin"; + public string Email { get; set; } = "admin@rag.local"; + public string Password { get; set; } = string.Empty; +} +``` + +Create `BootstrapAdminSeeder.cs`: + +```csharp +using BuildFlow.Platform.Domain.Entities.User; +using BuildFlow.Platform.Domain.Entities.UserRole; +using BuildFlow.Platform.Domain.Services; +using BuildFlow.Platform.Infrastructure.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace BuildFlow.Platform.Infrastructure.Persistence; + +/// 按部署配置幂等初始化最高权限管理员。 +public static class BootstrapAdminSeeder +{ + public static readonly Guid AdminRoleId = + Guid.Parse("20000000-0000-0000-0000-000000000002"); + + public static async Task SeedAsync( + PlatformDbContext db, + BootstrapAdminOptions options, + CancellationToken ct = default) + { + if (!options.Enabled) return false; + + if (string.IsNullOrWhiteSpace(options.Username)) + throw new InvalidOperationException("BootstrapAdmin 用户名不能为空"); + if (string.IsNullOrWhiteSpace(options.Email)) + throw new InvalidOperationException("BootstrapAdmin 邮箱不能为空"); + + var existingAdminId = await db.UserRoles + .Where(x => x.RoleId == AdminRoleId) + .Select(x => x.UserId) + .FirstOrDefaultAsync(ct); + if (existingAdminId != Guid.Empty) return false; + + var passwordError = PasswordPolicy.GetValidationError(options.Password); + if (passwordError is not null) + throw new InvalidOperationException($"BootstrapAdmin 配置无效:{passwordError}"); + + if (!await db.Roles.AnyAsync(x => x.Id == AdminRoleId, ct)) + throw new InvalidOperationException("BootstrapAdmin 初始化失败:Admin 角色不存在"); + + var conflictingUser = await db.Users.FirstOrDefaultAsync( + x => x.Username == options.Username || x.Email == options.Email, + ct); + if (conflictingUser is not null) + { + throw new InvalidOperationException( + "BootstrapAdmin 用户名或邮箱已被非管理员账号占用"); + } + + var user = new UserEntity + { + Id = Guid.NewGuid(), + Username = options.Username, + Email = options.Email, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(options.Password), + }; + await db.Users.AddAsync(user, ct); + await db.UserRoles.AddAsync(new UserRoleEntity + { + UserId = user.Id, + RoleId = AdminRoleId, + }, ct); + + await db.SaveChangesAsync(ct); + return true; + } +} +``` + +- [ ] **Step 5: Remove the legacy admin block and wire configuration** + +Delete the complete `// 管理员用户` and `// 确保管理员有 Admin 角色` blocks from `SeedData.SeedAsync`. Do not alter role, permission, menu, or IM demo seeds. + +In `BuildFlowHostModule.ConfigureServices`, bind the options: + +```csharp +services.Configure( + config.GetSection(BootstrapAdminOptions.SectionName)); +``` + +In `DatabaseInitializer`, split the current combined platform/workflow seed action into two `SafeRunAsync` calls. This lets an invalid bootstrap secret fail and log the Platform seed step without suppressing the independent Workflow seed. The platform action is: + +```csharp +await SafeRunAsync( + async () => + { + var platformDb = sp.GetRequiredService(); + await PlatformSeedData.SeedAsync(platformDb); + + var bootstrapOptions = + sp.GetRequiredService>().Value; + var adminCreated = await BootstrapAdminSeeder.SeedAsync( + platformDb, + bootstrapOptions); + logger.LogInformation(adminCreated + ? "Bootstrap admin 已创建" + : "Bootstrap admin 已跳过"); + }, + logger, + "Platform Seed"); +``` + +Use this second action for Workflow: + +```csharp +await SafeRunAsync( + async () => + { + var wfDb = sp.GetRequiredService(); + await WorkflowSeedData.SeedAsync(wfDb); + + var (defs, snaps, insts) = await WorkflowGraphMigration.RunAsync(wfDb); + if (defs > 0 || snaps > 0 || insts > 0) + { + logger.LogInformation( + "Workflow graph migration: {Defs} definitions, {Snaps} snapshots, {Insts} instances backfilled", + defs, + snaps, + insts); + } + }, + logger, + "Workflow Seed"); +``` + +Add the required `BuildFlow.Platform.Infrastructure.Configuration` and `Microsoft.Extensions.Options` imports. + +Add this default section to `appsettings.json`: + +```json +"Platform": { + "BootstrapAdmin": { + "Enabled": false, + "Username": "admin", + "Email": "admin@rag.local", + "Password": "" + } +} +``` + +Add this example section to `appsettings.Secrets.example.json`: + +```json +"Platform": { + "BootstrapAdmin": { + "Enabled": false, + "Username": "admin", + "Email": "admin@your-company.example", + "Password": "YOUR_STRONG_BOOTSTRAP_PASSWORD" + } +} +``` + +- [ ] **Step 6: Run focused and module tests** + +Run: + +```bash +dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore --filter "FullyQualifiedName~PasswordPolicyTests|FullyQualifiedName~BootstrapAdminSeederTests" +dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore +``` + +Expected: both commands PASS with 0 failed; the focused command passes 11 cases. + +- [ ] **Step 7: Scan for the fixed password and commit** + +Run: + +```bash +rg -n "admin123" src +``` + +Expected: no output. + +```bash +git add src/modules/platform/Domain/Services/PasswordPolicy.cs src/modules/platform/Infrastructure/Configuration/BootstrapAdminOptions.cs src/modules/platform/Infrastructure/Persistence/BootstrapAdminSeeder.cs src/modules/platform/Application/Auth/Validators/RegisterCommandValidator.cs src/BuildFlow.Host/Endpoints/Platform/Auth/RegisterRequestValidator.cs src/modules/platform/Infrastructure/Persistence/SeedData.cs src/BuildFlow.Host/BuildFlowHostModule.cs src/BuildFlow.Host/Configuration/DatabaseInitializer.cs src/BuildFlow.Host/appsettings.json src/BuildFlow.Host/appsettings.Secrets.example.json tests/BuildFlow.Platform.Tests/Auth/PasswordPolicyTests.cs tests/BuildFlow.Platform.Tests/Auth/BootstrapAdminSeederTests.cs +git commit -m "fix: secure bootstrap administrator creation" +``` + +### Task 2: Add A Bounded Obsidian Vault Scanner + +**Files:** +- Create: `src/modules/rag/Infrastructure/Obsidian/ObsidianOptions.cs` +- Create: `src/modules/rag/Infrastructure/Obsidian/ObsidianVaultFile.cs` +- Create: `src/modules/rag/Infrastructure/Obsidian/ObsidianVaultScanner.cs` +- Modify: `src/modules/rag/Infrastructure/RAGInfrastructureModule.cs` +- Modify: `src/BuildFlow.Host/appsettings.json` +- Create: `tests/BuildFlow.Rag.Tests/ObsidianVaultScannerTests.cs` +- Create: `tests/BuildFlow.Rag.Tests/ObsidianVaultScannerCollection.cs` + +- [ ] **Step 1: Write failing scanner boundary tests** + +Create `ObsidianVaultScannerCollection.cs`: + +```csharp +using Xunit; + +namespace BuildFlow.Rag.Tests; + +[CollectionDefinition("ObsidianVaultScanner", DisableParallelization = true)] +public sealed class ObsidianVaultScannerCollection +{ + public const string Name = "ObsidianVaultScanner"; +} +``` + +Create `ObsidianVaultScannerTests.cs`: + +```csharp +using System.Security.Cryptography; +using BuildFlow.Infrastructure.Obsidian; +using BuildFlow.Shared.Domain.Errors; +using BuildFlow.Shared.Domain.Exceptions; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +[Collection(ObsidianVaultScannerCollection.Name)] +public sealed class ObsidianVaultScannerTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + $"buildflow-obsidian-{Guid.NewGuid():N}"); + private readonly string _allowedRoot; + private readonly string _outsideRoot; + + public ObsidianVaultScannerTests() + { + _allowedRoot = Directory.CreateDirectory(Path.Combine(_testRoot, "allowed")).FullName; + _outsideRoot = Directory.CreateDirectory(Path.Combine(_testRoot, "outside")).FullName; + } + +[Fact] +public async Task OutsideAllowedRootShouldBeRejected() +{ + var scanner = CreateScanner(_allowedRoot); + var act = () => CollectAsync(scanner.ScanAsync(_outsideRoot)); + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.InvalidParam); +} + +[Fact] +public async Task ParentDirectoryTraversalShouldBeRejected() +{ + var traversal = Path.Combine(_allowedRoot, "child", "..", "..", "outside"); + var scanner = CreateScanner(_allowedRoot); + + var act = () => CollectAsync(scanner.ScanAsync(traversal)); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.InvalidParam); +} + +[Fact] +public async Task SymlinkEscapingAllowedRootShouldBeRejected() +{ + var link = Path.Combine(_allowedRoot, "escape"); + Directory.CreateSymbolicLink(link, _outsideRoot); + await File.WriteAllTextAsync(Path.Combine(_outsideRoot, "secret.md"), "secret"); + var scanner = CreateScanner(_allowedRoot); + + var act = () => CollectAsync(scanner.ScanAsync(_allowedRoot)); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.InvalidParam); +} + +[Fact] +public async Task FileCountLimitShouldUseTooLargeCode() +{ + await File.WriteAllTextAsync(Path.Combine(_allowedRoot, "1.md"), "1"); + await File.WriteAllTextAsync(Path.Combine(_allowedRoot, "2.md"), "2"); + var scanner = CreateScanner(_allowedRoot, maxFiles: 1); + + var act = () => CollectAsync(scanner.ScanAsync(_allowedRoot)); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.TooLarge); +} + +[Fact] +public async Task SingleAndTotalSizeLimitsShouldUseTooLargeCode() +{ + await File.WriteAllBytesAsync(Path.Combine(_allowedRoot, "large.md"), new byte[32]); + var singleScanner = CreateScanner(_allowedRoot, maxFileBytes: 16); + var singleAct = () => CollectAsync(singleScanner.ScanAsync(_allowedRoot)); + await singleAct.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.TooLarge); + + await File.WriteAllBytesAsync(Path.Combine(_allowedRoot, "small.md"), new byte[8]); + var totalScanner = CreateScanner(_allowedRoot, maxFileBytes: 64, maxTotalBytes: 32); + var totalAct = () => CollectAsync(totalScanner.ScanAsync(_allowedRoot)); + await totalAct.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.TooLarge); +} + +[Fact] +public async Task ValidVaultShouldReturnRelativePathSizeAndStreamingHash() +{ + var nested = Directory.CreateDirectory(Path.Combine(_allowedRoot, "Daily")); + var path = Path.Combine(nested.FullName, "note.md"); + await File.WriteAllTextAsync(path, "hello"); + var files = await CollectAsync(CreateScanner(_allowedRoot).ScanAsync(_allowedRoot)); + + files.Should().ContainSingle(); + files[0].RelativePath.Should().Be("Daily/note.md"); + files[0].FileSize.Should().Be(5); + files[0].ContentHash.Should().Be( + Convert.ToHexString(SHA256.HashData("hello"u8)).ToLowerInvariant()); +} + +[Fact] +public async Task LargeVaultShouldNotAllocateTheCombinedFileSize() +{ + for (var i = 0; i < 8; i++) + { + await using var stream = File.Create(Path.Combine(_allowedRoot, $"{i}.md")); + stream.SetLength(8L * 1024 * 1024); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var baseline = GC.GetTotalMemory(forceFullCollection: true); + var peak = baseline; + var count = 0; + + await foreach (var _ in CreateScanner( + _allowedRoot, + maxFileBytes: 9L * 1024 * 1024, + maxTotalBytes: 70L * 1024 * 1024).ScanAsync(_allowedRoot)) + { + count++; + peak = Math.Max(peak, GC.GetTotalMemory(forceFullCollection: false)); + } + + count.Should().Be(8); + (peak - baseline).Should().BeLessThan(32L * 1024 * 1024); +} + +private static ObsidianVaultScanner CreateScanner( + string allowedRoot, + int maxFiles = 100, + long maxFileBytes = 16L * 1024 * 1024, + long maxTotalBytes = 128L * 1024 * 1024) +{ + var options = Options.Create(new ObsidianOptions + { + AllowedRoots = [allowedRoot], + MaxFiles = maxFiles, + MaxFileBytes = maxFileBytes, + MaxTotalBytes = maxTotalBytes, + }); + return new ObsidianVaultScanner( + options, + NullLogger.Instance); +} + +private static async Task> CollectAsync( + IAsyncEnumerable source) +{ + var result = new List(); + await foreach (var item in source) result.Add(item); + return result; +} + +public void Dispose() +{ + if (Directory.Exists(_testRoot)) Directory.Delete(_testRoot, recursive: true); +} +} +``` + +- [ ] **Step 2: Run the scanner tests and verify RED** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~ObsidianVaultScannerTests +``` + +Expected: FAIL to compile because the Obsidian scanner types do not exist. + +- [ ] **Step 3: Add the options and scanner output contract** + +Create `ObsidianOptions.cs`: + +```csharp +namespace BuildFlow.Infrastructure.Obsidian; + +/// Obsidian 服务端目录访问与容量限制。 +public sealed class ObsidianOptions +{ + public const string SectionName = "Rag:Obsidian"; + + public string[] AllowedRoots { get; set; } = []; + public int MaxFiles { get; set; } = 10_000; + public long MaxFileBytes { get; set; } = 10 * 1024 * 1024; + public long MaxTotalBytes { get; set; } = 512 * 1024 * 1024; +} +``` + +Create `ObsidianVaultFile.cs`: + +```csharp +namespace BuildFlow.Infrastructure.Obsidian; + +/// 通过安全边界检查后的 Obsidian Markdown 文件。 +public sealed record ObsidianVaultFile( + string FullPath, + string RelativePath, + string FileName, + long FileSize, + string ContentHash); +``` + +- [ ] **Step 4: Implement streaming enumeration and hashing** + +Create `ObsidianVaultScanner.cs` with these required implementation points: + +```csharp +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using BuildFlow.Shared.Domain.Exceptions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BuildFlow.Infrastructure.Obsidian; + +/// 在允许根目录内流式枚举并计算 Obsidian Markdown 文件元数据。 +public sealed class ObsidianVaultScanner( + IOptions options, + ILogger logger) +{ + private static readonly HashSet IgnoredDirectories = + new(StringComparer.OrdinalIgnoreCase) { ".obsidian", ".trash", ".git", "node_modules" }; + private readonly ObsidianOptions _options = options.Value; + + public async IAsyncEnumerable ScanAsync( + string requestedDirectory, + [EnumeratorCancellation] CancellationToken ct = default) + { + var (vaultRoot, allowedRoot) = ResolveVaultRoot(requestedDirectory); + var pending = new Stack<(DirectoryInfo Directory, string RelativeDirectory)>(); + var visited = new HashSet(PathComparer); + pending.Push((new DirectoryInfo(vaultRoot), string.Empty)); + var fileCount = 0; + long totalBytes = 0; + + while (pending.TryPop(out var current)) + { + ThrowIfCancellationRequested(ct, current.Directory.FullName); + var currentPath = ResolveEntry(current.Directory, allowedRoot); + if (!visited.Add(currentPath)) continue; + + using var entries = new DirectoryInfo(currentPath) + .EnumerateFileSystemInfos() + .GetEnumerator(); + + while (MoveNext(entries, currentPath, out var entry)) + { + ThrowIfCancellationRequested(ct, currentPath); + if (entry is null) continue; + + if ((entry.Attributes & FileAttributes.Directory) != 0) + { + if (IgnoredDirectories.Contains(entry.Name)) continue; + var childPath = ResolveEntry(entry, allowedRoot); + var childRelative = string.IsNullOrEmpty(current.RelativeDirectory) + ? entry.Name + : Path.Combine(current.RelativeDirectory, entry.Name); + pending.Push((new DirectoryInfo(childPath), childRelative)); + continue; + } + + if (!string.Equals(entry.Extension, ".md", StringComparison.OrdinalIgnoreCase)) + continue; + + var physicalFile = ResolveEntry(entry, allowedRoot); + var fileInfo = new FileInfo(physicalFile); + fileInfo.Refresh(); + if (!fileInfo.Exists) + { + logger.LogWarning("Obsidian 文件在扫描期间消失:{Path}", physicalFile); + continue; + } + + fileCount++; + if (fileCount > _options.MaxFiles) + throw new BusinessException(ErrorCode.TooLarge, "Obsidian 文件数量超过配置上限"); + if (fileInfo.Length > _options.MaxFileBytes) + throw new BusinessException(ErrorCode.TooLarge, $"Obsidian 文件超过单文件大小上限:{entry.Name}"); + + if (fileInfo.Length > _options.MaxTotalBytes - totalBytes) + throw new BusinessException(ErrorCode.TooLarge, "Obsidian 文件累计大小超过配置上限"); + totalBytes += fileInfo.Length; + + byte[] digest; + try + { + await using var stream = new FileStream( + physicalFile, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + digest = await SHA256.HashDataAsync(stream, ct); + } + catch (FileNotFoundException ex) + { + logger.LogWarning(ex, "Obsidian 文件在读取前消失:{Path}", physicalFile); + continue; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + logger.LogInformation("Obsidian 文件扫描已取消:{Path}", physicalFile); + throw; + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Obsidian 文件无读取权限:{Path}", physicalFile); + throw new BusinessException(ErrorCode.Business, "Obsidian 文件无读取权限"); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Obsidian 文件读取失败:{Path}", physicalFile); + throw new BusinessException(ErrorCode.Business, "Obsidian 文件读取失败"); + } + + var relativePath = string.IsNullOrEmpty(current.RelativeDirectory) + ? entry.Name + : Path.Combine(current.RelativeDirectory, entry.Name); + yield return new ObsidianVaultFile( + physicalFile, + relativePath.Replace('\\', '/'), + entry.Name, + fileInfo.Length, + Convert.ToHexString(digest).ToLowerInvariant()); + } + } + } + + private (string VaultRoot, string AllowedRoot) ResolveVaultRoot(string requestedDirectory) + { + if (_options.AllowedRoots.Length == 0) + throw new BusinessException(ErrorCode.InvalidParam, "未配置允许的 Obsidian 根目录"); + if (requestedDirectory.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries).Contains("..", StringComparer.Ordinal)) + throw new BusinessException(ErrorCode.InvalidParam, "Obsidian 路径不能包含父目录跳转"); + + var requested = TrimPath(Path.GetFullPath(requestedDirectory)); + foreach (var configuredRoot in _options.AllowedRoots.Where(x => !string.IsNullOrWhiteSpace(x))) + { + var lexicalRoot = TrimPath(Path.GetFullPath(configuredRoot)); + if (!Directory.Exists(lexicalRoot) || !IsWithin(requested, lexicalRoot)) continue; + + var physicalRoot = ResolveLink(new DirectoryInfo(lexicalRoot)); + var relative = Path.GetRelativePath(lexicalRoot, requested); + var physicalVault = ResolveRelativeDirectory(physicalRoot, relative); + EnsureWithin(physicalVault, physicalRoot); + return (physicalVault, physicalRoot); + } + + throw new BusinessException(ErrorCode.InvalidParam, "Obsidian 路径不在允许的根目录内"); + } + + private string ResolveRelativeDirectory(string physicalRoot, string relative) + { + var current = physicalRoot; + if (relative == ".") return current; + + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + var next = new DirectoryInfo(Path.Combine(current, segment)); + if (!next.Exists) + throw new BusinessException(ErrorCode.InvalidParam, "Vault 目录不存在"); + current = ResolveLink(next); + EnsureWithin(current, physicalRoot); + } + + return current; + } + + private string ResolveEntry(FileSystemInfo entry, string allowedRoot) + { + var resolved = ResolveLink(entry); + EnsureWithin(resolved, allowedRoot); + return resolved; + } + + private static string ResolveLink(FileSystemInfo entry) + { + if ((entry.Attributes & FileAttributes.ReparsePoint) == 0) + return TrimPath(Path.GetFullPath(entry.FullName)); + + var target = entry.ResolveLinkTarget(returnFinalTarget: true) + ?? throw new BusinessException(ErrorCode.InvalidParam, "Obsidian 符号链接目标不存在"); + return TrimPath(Path.GetFullPath(target.FullName)); + } + + private bool MoveNext( + IEnumerator entries, + string directory, + out FileSystemInfo? entry) + { + try + { + if (!entries.MoveNext()) + { + entry = null; + return false; + } + + entry = entries.Current; + return true; + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Obsidian 目录无枚举权限:{Path}", directory); + throw new BusinessException(ErrorCode.Business, "Obsidian 目录无枚举权限"); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Obsidian 目录枚举失败:{Path}", directory); + throw new BusinessException(ErrorCode.Business, "Obsidian 目录枚举失败"); + } + } + + private void ThrowIfCancellationRequested(CancellationToken ct, string path) + { + if (!ct.IsCancellationRequested) return; + logger.LogInformation("Obsidian 目录扫描已取消:{Path}", path); + ct.ThrowIfCancellationRequested(); + } + + private static void EnsureWithin(string path, string root) + { + if (!IsWithin(path, root)) + throw new BusinessException(ErrorCode.InvalidParam, "Obsidian 符号链接逃逸允许根目录"); + } + + private static bool IsWithin(string path, string root) + { + var relative = Path.GetRelativePath(root, path); + return relative == "." || + (!Path.IsPathRooted(relative) && + relative != ".." && + !relative.StartsWith($"..{Path.DirectorySeparatorChar}", PathComparison) && + !relative.StartsWith($"..{Path.AltDirectorySeparatorChar}", PathComparison)); + } + + private static string TrimPath(string path) => Path.TrimEndingDirectorySeparator(path); + private static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private static StringComparer PathComparer => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; +} +``` + +- [ ] **Step 5: Bind limits and default to no allowed roots** + +In `RAGInfrastructureModule.ConfigureServices`: + +```csharp +services.AddOptions() + .Bind(config.GetSection(ObsidianOptions.SectionName)) + .Validate(x => x.MaxFiles > 0, "Rag:Obsidian:MaxFiles 必须大于 0") + .Validate(x => x.MaxFileBytes > 0, "Rag:Obsidian:MaxFileBytes 必须大于 0") + .Validate(x => x.MaxTotalBytes > 0, "Rag:Obsidian:MaxTotalBytes 必须大于 0"); +services.AddScoped(); +``` + +Add the namespace import and this `appsettings.json` section. An empty root list deliberately disables server-directory access until deployment configuration opts in: + +```json +"Rag": { + "Obsidian": { + "AllowedRoots": [], + "MaxFiles": 10000, + "MaxFileBytes": 10485760, + "MaxTotalBytes": 536870912 + } +} +``` + +- [ ] **Step 6: Verify scanner tests pass** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~ObsidianVaultScannerTests +``` + +Expected: PASS with all root, symlink, count, size, hash, and bounded-memory tests successful. + +- [ ] **Step 7: Commit the scanner boundary** + +```bash +git add src/modules/rag/Infrastructure/Obsidian src/modules/rag/Infrastructure/RAGInfrastructureModule.cs src/BuildFlow.Host/appsettings.json tests/BuildFlow.Rag.Tests/ObsidianVaultScannerTests.cs tests/BuildFlow.Rag.Tests/ObsidianVaultScannerCollection.cs +git commit -m "feat: bound Obsidian filesystem access" +``` + +### Task 3: Enforce Obsidian Permission And Knowledge Base Ownership + +**Files:** +- Modify: `src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs:14-29` +- Modify: `src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs` +- Create: `tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs` +- Create: `tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs` + +- [ ] **Step 1: Write failing ownership and Endpoint contract tests** + +Create `ObsidianSyncSecurityTests.cs`: + +```csharp +using BuildFlow.Application.Obsidian.Commands; +using BuildFlow.Application.Obsidian.Handlers; +using BuildFlow.Domain.Entities.KnowledgeBase; +using BuildFlow.Infrastructure.Obsidian; +using BuildFlow.Infrastructure.Persistence; +using BuildFlow.Rag.Tests.Stub; +using BuildFlow.Shared.Domain.Errors; +using BuildFlow.Shared.Domain.Exceptions; +using BuildFlow.Shared.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +public sealed class ObsidianSyncSecurityTests : IDisposable +{ + private readonly string _allowedRoot = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + $"buildflow-obsidian-sync-{Guid.NewGuid():N}")).FullName; + +[Fact] +public async Task NonOwnerShouldReceiveNotFoundWithoutScanningFiles() +{ + var owner = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() }; + await using var db = CreateDb(owner); + var kb = new KnowledgeBaseEntity { Name = "private" }; + db.KnowledgeBases.Add(kb); + await db.SaveChangesAsync(); + var caller = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() }; + var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner()); + + var act = () => handler.Handle( + new SyncObsidianVaultCommand(kb.Id, _allowedRoot), + CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound); + db.Documents.Should().BeEmpty(); +} + +[Fact] +public async Task OwnerSyncShouldAddThenSkipAnUnchangedFile() +{ + var caller = new TestCurrentUserContext(); + await using var db = CreateDb(caller); + var kb = new KnowledgeBaseEntity { Name = "owned" }; + db.KnowledgeBases.Add(kb); + await db.SaveChangesAsync(); + await File.WriteAllTextAsync(Path.Combine(_allowedRoot, "note.md"), "hello"); + var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner()); + + var first = await handler.Handle( + new SyncObsidianVaultCommand(kb.Id, _allowedRoot), + CancellationToken.None); + var second = await handler.Handle( + new SyncObsidianVaultCommand(kb.Id, _allowedRoot), + CancellationToken.None); + + first.Added.Should().Be(1); + second.Skipped.Should().Be(1); + db.Documents.Should().ContainSingle(); +} + +private static RagDbContext CreateDb(TestCurrentUserContext userContext) +{ + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"obsidian-sync-{Guid.NewGuid():N}") + .AddInterceptors(new AuditInterceptor(userContext)) + .Options; + return new RagDbContext(options); +} + +private ObsidianVaultScanner CreateScanner() +{ + return new ObsidianVaultScanner( + Options.Create(new ObsidianOptions + { + AllowedRoots = [_allowedRoot], + MaxFiles = 100, + MaxFileBytes = 1024 * 1024, + MaxTotalBytes = 10 * 1024 * 1024, + }), + NullLogger.Instance); +} + +public void Dispose() +{ + if (Directory.Exists(_allowedRoot)) Directory.Delete(_allowedRoot, recursive: true); +} +} +``` + +Create `RagSecurityArchitectureTests.cs`: + +```csharp +namespace BuildFlow.Architecture.Tests; + +using Xunit; + +public class RagSecurityArchitectureTests +{ +[Fact] +public void ObsidianSyncEndpointShouldRequireKnowledgeWritePermission() +{ + var source = ReadSource("src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs"); + Assert.Contains("Permissions(\"knowledge:write\")", source); + Assert.DoesNotContain("AllowAnonymous()", source); +} + +private static string ReadSource(string relativePath) +{ + return File.ReadAllText(Path.Combine( + FindRepoRoot().FullName, + relativePath.Replace('/', Path.DirectorySeparatorChar))); +} + +private static DirectoryInfo FindRepoRoot() +{ + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx"))) return current; + current = current.Parent; + } + + throw new InvalidOperationException("无法定位 BuildFlow.slnx,无法验证 RAG 安全边界。"); +} +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~ObsidianSyncSecurityTests +dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~RagSecurityArchitectureTests +``` + +Expected: FAIL because the handler lacks user context/scanner dependencies and the Endpoint is anonymous. + +- [ ] **Step 3: Require the existing knowledge-base write permission** + +Change Endpoint configuration to: + +```csharp +public override void Configure() +{ + Post("/knowledge-bases/{KnowledgeBaseId}/obsidian/sync"); + Permissions("knowledge:write"); +} +``` + +Keep the route, request, response, and chunking-mode mapping unchanged. + +- [ ] **Step 4: Replace direct filesystem access with ownership plus scanner output** + +Change the handler constructor and initial query to: + +```csharp +public class SyncObsidianVaultCommandHandler( + RagDbContext db, + ICurrentUserContext userContext, + ObsidianVaultScanner scanner) + : IRequestHandler +{ + public async Task Handle( + SyncObsidianVaultCommand request, + CancellationToken ct) + { + var userId = userContext.GetRequiredUserId(); + var kb = await db.KnowledgeBases.FirstOrDefaultAsync( + x => x.Id == request.KnowledgeBaseId && x.CreatedBy == userId, + ct) + ?? throw new NotFoundException(ErrorCode.NotFound, "知识库不存在"); +``` + +Keep the existing one-query dictionary of Obsidian documents. Replace `Directory.EnumerateFiles(...).ToList()`, `ReadAllBytesAsync`, and local SHA256 calculation with: + +```csharp +var added = 0; +var updated = 0; +var skipped = 0; +var totalFiles = 0; + +await foreach (var file in scanner.ScanAsync(request.VaultDirectory, ct)) +{ + totalFiles++; + if (existing.TryGetValue(file.RelativePath, out var doc)) + { + if (doc.ContentHash == file.ContentHash) + { + skipped++; + continue; + } + + doc.FilePath = file.FullPath; + doc.FileName = file.FileName; + doc.FileSize = file.FileSize; + doc.ContentHash = file.ContentHash; + doc.Status = DocumentStatusEnum.Pending; + doc.VaultPath = file.RelativePath; + updated++; + continue; + } + + db.Documents.Add(new DocumentEntity + { + KnowledgeBaseId = kb.Id, + Title = file.FileName, + FileName = file.FileName, + FilePath = file.FullPath, + FileSize = file.FileSize, + ContentType = "text/markdown", + Status = DocumentStatusEnum.Pending, + SourceType = DocumentSourceTypeEnum.Obsidian, + VaultPath = file.RelativePath, + ContentHash = file.ContentHash, + ChunkingModeOverride = request.ChunkingModeOverride, + }); + added++; +} + +await db.SaveChangesAsync(ct); +return new ObsidianSyncResult( + kb.Id, + Path.GetFullPath(request.VaultDirectory), + totalFiles, + added, + updated, + skipped, + $"同步完成:新增 {added},更新 {updated},跳过 {skipped}(未变更)。"); +``` + +Delete `ObsidianIgnoreDirs`, `System.Security.Cryptography`, `Directory.Exists`, and all direct file byte reads from the handler. + +- [ ] **Step 5: Verify Obsidian security tests and scans** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter "FullyQualifiedName~ObsidianVaultScannerTests|FullyQualifiedName~ObsidianSyncSecurityTests" +dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~RagSecurityArchitectureTests +rg -n "AllowAnonymous\(\)" src/BuildFlow.Host/Endpoints/Rag/Obsidian +rg -n "ReadAllBytesAsync|EnumerateFiles\(.*AllDirectories.*ToList" src/modules/rag/Application/Obsidian +``` + +Expected: tests PASS; both scans produce no output. + +- [ ] **Step 6: Commit Obsidian authorization and ownership** + +```bash +git add src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs +git commit -m "fix: enforce Obsidian ownership and permission" +``` + +### Task 4: Enforce Ownership In Non-Streaming Conversation Handlers + +**Files:** +- Modify: `src/modules/rag/Application/Chat/Handlers/GetConversationDetailQueryHandler.cs` +- Modify: `src/modules/rag/Application/Chat/Handlers/UpdateConversationCommandHandler.cs` +- Modify: `src/modules/rag/Application/Chat/Handlers/DeleteConversationCommandHandler.cs` +- Modify: `src/modules/rag/Application/Chat/Handlers/SendMessageCommandHandler.cs` +- Modify: `tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs` +- Modify: `tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs` + +- [ ] **Step 1: Add failing cross-user tests and update constructor calls** + +Update every affected test constructor to pass a `TestCurrentUserContext`: + +```csharp +new SendMessageCommandHandler(db, chatAgent, cache, userContext) +new GetConversationDetailQueryHandler(db, cache, userContext) +new DeleteConversationCommandHandler(db, cache, userContext) +``` + +Add this send test to `ChatEndToEndTests.cs`: + +```csharp +[Fact] +public async Task SendMessage_OtherUsersConversation_ThrowsNotFoundWithoutWriting() +{ + await using var db = CreateDb("send-other-user"); + var userContext = new TestCurrentUserContext(); + var conversationId = Guid.NewGuid(); + SeedConversation(db, conversationId, Guid.NewGuid()); + var cache = new StubChatMessageCache(); + var handler = new SendMessageCommandHandler( + db, + new StubAIChatAgent(), + cache, + userContext); + var request = new SendMessageCommand(conversationId, "越权消息"); + + var act = () => handler.Handle(request, CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在"); + db.ChatMessages.Should().BeEmpty(); + cache.Count(conversationId).Should().Be(0); +} +``` + +Add these detail, update, and delete tests to `EmbeddingAndQueryTests.cs`: + +```csharp +[Fact] +public async Task GetConversationDetail_OtherUsersConversation_ThrowsNotFound() +{ + await using var db = CreateDb(); + var userContext = new TestCurrentUserContext(); + var conversationId = Guid.NewGuid(); + db.Conversations.Add(new ConversationEntity + { + Id = conversationId, + Title = "他人的详情", + UserId = Guid.NewGuid(), + }); + await db.SaveChangesAsync(); + var handler = new GetConversationDetailQueryHandler( + db, + new StubChatMessageCache(), + userContext); + var request = new GetConversationDetailQuery(conversationId); + + var act = () => handler.Handle(request, CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在"); +} + +[Fact] +public async Task UpdateConversation_OtherUsersConversation_DoesNotChangeTitle() +{ + await using var db = CreateDb(); + var userContext = new TestCurrentUserContext(); + var conversation = new ConversationEntity + { + Id = Guid.NewGuid(), + Title = "原始标题", + UserId = Guid.NewGuid(), + }; + db.Conversations.Add(conversation); + await db.SaveChangesAsync(); + var handler = new UpdateConversationCommandHandler(db, userContext); + var request = new UpdateConversationCommand(conversation.Id, "越权更新", null); + + var act = () => handler.Handle(request, CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在"); + conversation.Title.Should().Be("原始标题"); +} + +[Fact] +public async Task DeleteConversation_OtherUsersConversation_LeavesDbAndCacheUntouched() +{ + await using var db = CreateDb(); + var userContext = new TestCurrentUserContext(); + var conversationId = Guid.NewGuid(); + db.Conversations.Add(new ConversationEntity + { + Id = conversationId, + Title = "不可删除", + UserId = Guid.NewGuid(), + }); + await db.SaveChangesAsync(); + var cache = new StubChatMessageCache(); + await cache.SetMessagesAsync(conversationId, + [new CachedChatMessage(Guid.NewGuid(), "user", "保留", null, DateTime.UtcNow)]); + var handler = new DeleteConversationCommandHandler(db, cache, userContext); + var request = new DeleteConversationCommand(conversationId); + + var act = () => handler.Handle(request, CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在"); + (await db.Conversations.AnyAsync(x => x.Id == conversationId)).Should().BeTrue(); + cache.Count(conversationId).Should().Be(1); +} +``` + +Add `using BuildFlow.Shared.Domain.Errors;`, `using BuildFlow.Shared.Domain.Exceptions;`, and `using BuildFlow.Application.Chat.Commands;` where those namespaces are not already imported. + +- [ ] **Step 2: Run the focused chat tests and verify RED** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter "FullyQualifiedName~ChatEndToEndTests|FullyQualifiedName~EmbeddingAndQueryTests" +``` + +Expected: FAIL because the current handlers can access conversations owned by another user. + +- [ ] **Step 3: Add current-user dependencies and combined predicates** + +Use this exact lookup in all four handlers, changing `request.Id` to `request.ConversationId` for send: + +```csharp +var userId = Guid.Parse(userContext.GetRequiredUserId()); +var conversation = await db.Conversations + .FirstOrDefaultAsync( + x => x.Id == request.Id && x.UserId == userId, + ct) + ?? throw new NotFoundException(ErrorCode.NotFound, "会话不存在"); +``` + +Constructor signatures become: + +```csharp +GetConversationDetailQueryHandler( + RagDbContext db, + IChatMessageCache cache, + ICurrentUserContext userContext) + +UpdateConversationCommandHandler( + RagDbContext db, + ICurrentUserContext userContext) + +DeleteConversationCommandHandler( + RagDbContext db, + IChatMessageCache cache, + ICurrentUserContext userContext) + +SendMessageCommandHandler( + RagDbContext db, + IAIChatAgent chatAgent, + IChatMessageCache cache, + ICurrentUserContext userContext) +``` + +Do not read or mutate cache, messages, or conversation fields before the combined predicate succeeds. Replace the update handler's `BusinessException(ErrorCode.NotFound, ...)` with `NotFoundException(ErrorCode.NotFound, ...)`. + +- [ ] **Step 4: Verify all non-streaming ownership tests** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter "FullyQualifiedName~ChatEndToEndTests|FullyQualifiedName~EmbeddingAndQueryTests" +``` + +Expected: PASS with own-user behavior unchanged and all four cross-user operations returning NotFound. + +- [ ] **Step 5: Commit non-streaming ownership** + +```bash +git add src/modules/rag/Application/Chat/Handlers/GetConversationDetailQueryHandler.cs src/modules/rag/Application/Chat/Handlers/UpdateConversationCommandHandler.cs src/modules/rag/Application/Chat/Handlers/DeleteConversationCommandHandler.cs src/modules/rag/Application/Chat/Handlers/SendMessageCommandHandler.cs tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs +git commit -m "fix: enforce RAG conversation ownership" +``` + +### Task 5: Move Streaming Conversation Operations Behind MediatR Ownership Checks + +**Files:** +- Create: `src/modules/rag/Application/Chat/Dtos/ChatStreamEvent.cs` +- Create: `src/modules/rag/Application/Chat/Commands/StreamMessageCommand.cs` +- Create: `src/modules/rag/Application/Chat/Commands/EditMessageStreamCommand.cs` +- Create: `src/modules/rag/Application/Chat/Handlers/StreamMessageCommandHandler.cs` +- Create: `src/modules/rag/Application/Chat/Handlers/EditMessageStreamCommandHandler.cs` +- Modify: `src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs` +- Modify: `src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs` +- Modify: `tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs` +- Create: `tests/BuildFlow.Rag.Tests/ChatStreamOwnershipTests.cs` + +- [ ] **Step 1: Write failing stream ownership and architecture tests** + +Create `ChatStreamOwnershipTests.cs`: + +```csharp +using BuildFlow.Application.Chat.Commands; +using BuildFlow.Application.Chat.DTOs; +using BuildFlow.Application.Chat.Handlers; +using BuildFlow.Domain.Entities.ChatMessage; +using BuildFlow.Domain.Entities.Conversation; +using BuildFlow.Domain.Enums; +using BuildFlow.Infrastructure.Persistence; +using BuildFlow.Rag.Tests.Stub; +using BuildFlow.Shared.Domain.Errors; +using BuildFlow.Shared.Domain.Exceptions; +using BuildFlow.Shared.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +public class ChatStreamOwnershipTests +{ +[Fact] +public async Task StreamMessageForOwnerShouldYieldChunksThenDoneAndPersistMessages() +{ + var userContext = new TestCurrentUserContext(); + await using var db = CreateDb(userContext); + var conversationId = await SeedConversationAsync( + db, + Guid.Parse(userContext.UserId)); + var cache = new StubChatMessageCache(); + var handler = new StreamMessageCommandHandler( + db, + new StubAIChatAgent { StreamTokens = ["你", "好", "!"] }, + cache, + userContext); + + var events = await CollectAsync(handler.Handle( + new StreamMessageCommand(conversationId, "你好"), + CancellationToken.None)); + + events.Take(3).Select(x => x.Content).Should().Equal("你", "好", "!"); + events.Should().HaveCount(4); + events[^1].MessageId.Should().NotBeNull(); + events[^1].MessageId!.Value.Should().NotBeEmpty(); + db.ChatMessages.Count(x => x.Role == ChatRoleEnum.User).Should().Be(1); + db.ChatMessages.Count(x => x.Role == ChatRoleEnum.Assistant).Should().Be(1); + cache.Count(conversationId).Should().Be(2); +} + +[Fact] +public async Task StreamMessageForNonOwnerShouldThrowNotFoundBeforeWriting() +{ + var userContext = new TestCurrentUserContext(); + await using var db = CreateDb(userContext); + var conversationId = await SeedConversationAsync(db, Guid.NewGuid()); + var cache = new StubChatMessageCache(); + var handler = new StreamMessageCommandHandler( + db, + new StubAIChatAgent(), + cache, + userContext); + + var act = () => CollectAsync(handler.Handle( + new StreamMessageCommand(conversationId, "越权消息"), + CancellationToken.None)); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在"); + db.ChatMessages.Should().BeEmpty(); + cache.Count(conversationId).Should().Be(0); +} + +[Fact] +public async Task EditStreamForNonOwnerShouldThrowNotFoundBeforeEditing() +{ + var userContext = new TestCurrentUserContext(); + await using var db = CreateDb(userContext); + var conversationId = await SeedConversationAsync(db, Guid.NewGuid()); + var target = new ChatMessageEntity + { + ConversationId = conversationId, + Role = ChatRoleEnum.User, + Content = "原始问题", + }; + var later = new ChatMessageEntity + { + ConversationId = conversationId, + Role = ChatRoleEnum.Assistant, + Content = "原始回答", + }; + db.ChatMessages.AddRange(target, later); + await db.SaveChangesAsync(); + var handler = new EditMessageStreamCommandHandler( + db, + new StubAIChatAgent(), + new StubChatMessageCache(), + userContext); + + var act = () => CollectAsync(handler.Handle( + new EditMessageStreamCommand(conversationId, target.Id, "越权编辑"), + CancellationToken.None)); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在"); + target.Content.Should().Be("原始问题"); + db.ChatMessages.Should().HaveCount(2); +} + +[Fact] +public async Task EditStreamForOwnerShouldPreserveChunkAndDoneEventContract() +{ + var userContext = new TestCurrentUserContext(); + await using var db = CreateDb(userContext); + var conversationId = await SeedConversationAsync( + db, + Guid.Parse(userContext.UserId)); + var target = new ChatMessageEntity + { + ConversationId = conversationId, + Role = ChatRoleEnum.User, + Content = "原始问题", + }; + db.ChatMessages.Add(target); + await db.SaveChangesAsync(); + await Task.Delay(5); + db.ChatMessages.Add(new ChatMessageEntity + { + ConversationId = conversationId, + Role = ChatRoleEnum.Assistant, + Content = "旧回答", + }); + await db.SaveChangesAsync(); + var cache = new StubChatMessageCache(); + var handler = new EditMessageStreamCommandHandler( + db, + new StubAIChatAgent { StreamTokens = ["新", "回", "答"] }, + cache, + userContext); + + var events = await CollectAsync(handler.Handle( + new EditMessageStreamCommand(conversationId, target.Id, "新问题"), + CancellationToken.None)); + + events.Take(3).Select(x => x.Content).Should().Equal("新", "回", "答"); + events[^1].MessageId.Should().NotBeNull(); + events[^1].MessageId!.Value.Should().NotBeEmpty(); + target.Content.Should().Be("新问题"); + db.ChatMessages.Should().HaveCount(2); + db.ChatMessages.Single(x => x.Role == ChatRoleEnum.Assistant).Content.Should().Be("新回答"); + cache.Count(conversationId).Should().Be(2); +} + +private static RagDbContext CreateDb(TestCurrentUserContext userContext) +{ + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"chat-stream-{Guid.NewGuid():N}") + .AddInterceptors(new AuditInterceptor(userContext)) + .Options; + return new RagDbContext(options); +} + +private static async Task SeedConversationAsync(RagDbContext db, Guid userId) +{ + var conversation = new ConversationEntity + { + Title = "测试会话", + UserId = userId, + }; + db.Conversations.Add(conversation); + await db.SaveChangesAsync(); + return conversation.Id; +} + +private static async Task> CollectAsync(IAsyncEnumerable source) +{ + var result = new List(); + await foreach (var item in source) result.Add(item); + return result; +} +} +``` + +Extend `RagSecurityArchitectureTests`: + +```csharp +[Theory] +[InlineData("src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs")] +[InlineData("src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs")] +public void ChatStreamEndpointsShouldUseMediatorWithoutDbContext(string relativePath) +{ + var source = ReadSource(relativePath); + Assert.Contains("IMediator mediator", source); + Assert.Contains("mediator.CreateStream", source); + Assert.DoesNotContain("RagDbContext", source); + Assert.DoesNotContain("IAIChatAgent", source); + Assert.DoesNotContain("IChatMessageCache", source); +} +``` + +- [ ] **Step 2: Run stream tests and verify RED** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~ChatStreamOwnershipTests +dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~RagSecurityArchitectureTests +``` + +Expected: FAIL because stream commands/handlers do not exist and both Endpoints still depend on `RagDbContext`. + +- [ ] **Step 3: Add the stream event and commands** + +Create `ChatStreamEvent.cs`: + +```csharp +namespace BuildFlow.Application.Chat.DTOs; + +/// 聊天流的内容片段或完成事件。 +public sealed record ChatStreamEvent(string? Content, Guid? MessageId) +{ + public static ChatStreamEvent Chunk(string content) => new(content, null); + public static ChatStreamEvent Done(Guid messageId) => new(null, messageId); +} +``` + +Create `StreamMessageCommand.cs`: + +```csharp +using BuildFlow.Application.Chat.DTOs; +using MediatR; + +namespace BuildFlow.Application.Chat.Commands; + +/// 向当前用户拥有的会话发送流式消息。 +public sealed record StreamMessageCommand(Guid ConversationId, string Content) + : IStreamRequest; +``` + +Create `EditMessageStreamCommand.cs`: + +```csharp +using BuildFlow.Application.Chat.DTOs; +using MediatR; + +namespace BuildFlow.Application.Chat.Commands; + +/// 编辑当前用户会话中的提问并重新流式生成回复。 +public sealed record EditMessageStreamCommand( + Guid ConversationId, + Guid MessageId, + string Content) : IStreamRequest; +``` + +- [ ] **Step 4: Implement streaming handlers with ownership as the first database operation** + +Create `StreamMessageCommandHandler.cs`: + +```csharp +using System.Runtime.CompilerServices; +using System.Text; +using BuildFlow.Application.Chat.Commands; +using BuildFlow.Application.Chat.DTOs; +using BuildFlow.Domain.Entities.ChatMessage; +using BuildFlow.Domain.Enums; +using BuildFlow.Domain.Interfaces; +using BuildFlow.Infrastructure.Persistence; +using BuildFlow.Shared.Domain.Common; +using BuildFlow.Shared.Domain.Exceptions; +using MediatR; +using AiChatMessage = Microsoft.Extensions.AI.ChatMessage; +using AiChatRole = Microsoft.Extensions.AI.ChatRole; + +namespace BuildFlow.Application.Chat.Handlers; + +/// 向当前用户会话发送消息并产出流式回复事件。 +public sealed class StreamMessageCommandHandler( + RagDbContext db, + IAIChatAgent chatAgent, + IChatMessageCache cache, + ICurrentUserContext userContext) + : IStreamRequestHandler +{ + public async IAsyncEnumerable Handle( + StreamMessageCommand request, + [EnumeratorCancellation] CancellationToken ct) + { + var userId = Guid.Parse(userContext.GetRequiredUserId()); + var conversation = await db.Conversations.FirstOrDefaultAsync( + x => x.Id == request.ConversationId && x.UserId == userId, + ct) + ?? throw new NotFoundException(ErrorCode.NotFound, "会话不存在"); + + var userMessage = new ChatMessageEntity + { + ConversationId = conversation.Id, + Role = ChatRoleEnum.User, + Content = request.Content, + }; + await db.ChatMessages.AddAsync(userMessage, ct); + await db.SaveChangesAsync(ct); + await cache.AppendMessageAsync( + conversation.Id, + new CachedChatMessage( + userMessage.Id, + ChatRoleEnum.User.ToString(), + userMessage.Content, + null, + userMessage.CreatedAt), + ct); + + var history = await db.ChatMessages + .Where(x => x.ConversationId == conversation.Id) + .OrderBy(x => x.CreatedAt) + .ToListAsync(ct); + var chatMessages = history.Select(x => new AiChatMessage( + x.Role == ChatRoleEnum.User ? AiChatRole.User : AiChatRole.Assistant, + x.Content)).ToList(); + + var fullReply = new StringBuilder(); + await foreach (var chunk in chatAgent.RunStreamingAsync(chatMessages, ct)) + { + fullReply.Append(chunk); + yield return ChatStreamEvent.Chunk(chunk); + } + + var assistantMessage = new ChatMessageEntity + { + ConversationId = conversation.Id, + Role = ChatRoleEnum.Assistant, + Content = fullReply.ToString(), + }; + await db.ChatMessages.AddAsync(assistantMessage, ct); + await db.SaveChangesAsync(ct); + await cache.AppendMessageAsync( + conversation.Id, + new CachedChatMessage( + assistantMessage.Id, + ChatRoleEnum.Assistant.ToString(), + assistantMessage.Content, + null, + assistantMessage.CreatedAt), + ct); + yield return ChatStreamEvent.Done(assistantMessage.Id); + } +} +``` + +Create `EditMessageStreamCommandHandler.cs`: + +```csharp +using System.Runtime.CompilerServices; +using System.Text; +using BuildFlow.Application.Chat.Commands; +using BuildFlow.Application.Chat.DTOs; +using BuildFlow.Domain.Entities.ChatMessage; +using BuildFlow.Domain.Enums; +using BuildFlow.Domain.Interfaces; +using BuildFlow.Infrastructure.Persistence; +using BuildFlow.Shared.Domain.Common; +using BuildFlow.Shared.Domain.Exceptions; +using MediatR; +using AiChatMessage = Microsoft.Extensions.AI.ChatMessage; +using AiChatRole = Microsoft.Extensions.AI.ChatRole; + +namespace BuildFlow.Application.Chat.Handlers; + +/// 编辑当前用户的提问,截断后续消息并重新流式生成回复。 +public sealed class EditMessageStreamCommandHandler( + RagDbContext db, + IAIChatAgent chatAgent, + IChatMessageCache cache, + ICurrentUserContext userContext) + : IStreamRequestHandler +{ + public async IAsyncEnumerable Handle( + EditMessageStreamCommand request, + [EnumeratorCancellation] CancellationToken ct) + { + var userIdText = userContext.GetRequiredUserId(); + var userId = Guid.Parse(userIdText); + var conversation = await db.Conversations.FirstOrDefaultAsync( + x => x.Id == request.ConversationId && x.UserId == userId, + ct) + ?? throw new NotFoundException(ErrorCode.NotFound, "会话不存在"); + + var targetMessage = await db.ChatMessages.FirstOrDefaultAsync( + x => x.Id == request.MessageId && x.ConversationId == conversation.Id, + ct) + ?? throw new NotFoundException(ErrorCode.NotFound, "消息不存在"); + if (targetMessage.Role != ChatRoleEnum.User) + throw new BusinessException(ErrorCode.Business, "仅支持编辑用户提问消息"); + + var targetCreatedAt = targetMessage.CreatedAt; + targetMessage.Content = request.Content; + targetMessage.UpdatedAt = DateTime.UtcNow; + targetMessage.UpdatedBy = userIdText; + + var messagesToRemove = await db.ChatMessages + .Where(x => x.ConversationId == conversation.Id && x.CreatedAt > targetCreatedAt) + .ToListAsync(ct); + db.ChatMessages.RemoveRange(messagesToRemove); + await db.SaveChangesAsync(ct); + + await cache.RemoveAsync(conversation.Id, ct); + var remainingMessages = await db.ChatMessages + .Where(x => x.ConversationId == conversation.Id) + .OrderBy(x => x.CreatedAt) + .ToListAsync(ct); + await cache.SetMessagesAsync( + conversation.Id, + remainingMessages.Select(x => new CachedChatMessage( + x.Id, + x.Role.ToString(), + x.Content, + x.TokenUsage, + x.CreatedAt)).ToList(), + ct); + + var chatMessages = remainingMessages.Select(x => new AiChatMessage( + x.Role == ChatRoleEnum.User ? AiChatRole.User : AiChatRole.Assistant, + x.Content)).ToList(); + var fullReply = new StringBuilder(); + await foreach (var chunk in chatAgent.RunStreamingAsync(chatMessages, ct)) + { + fullReply.Append(chunk); + yield return ChatStreamEvent.Chunk(chunk); + } + + var assistantMessage = new ChatMessageEntity + { + ConversationId = conversation.Id, + Role = ChatRoleEnum.Assistant, + Content = fullReply.ToString(), + }; + await db.ChatMessages.AddAsync(assistantMessage, ct); + await db.SaveChangesAsync(ct); + await cache.AppendMessageAsync( + conversation.Id, + new CachedChatMessage( + assistantMessage.Id, + ChatRoleEnum.Assistant.ToString(), + assistantMessage.Content, + null, + assistantMessage.CreatedAt), + ct); + yield return ChatStreamEvent.Done(assistantMessage.Id); + } +} +``` + +- [ ] **Step 5: Reduce both Endpoints to HTTP-to-MediatR SSE adapters** + +Both Endpoint constructors become `(IMediator mediator)`. Keep routes and `Permissions("chat:send")`. The normal stream handler body becomes: + +```csharp +var conversationId = Route("ConversationId"); +HttpContext.Response.ContentType = "text/event-stream"; +HttpContext.Response.Headers.CacheControl = "no-cache"; +HttpContext.Response.Headers.Connection = "keep-alive"; + +await foreach (var streamEvent in mediator.CreateStream( + new StreamMessageCommand(conversationId, req.Content), + ct)) +{ + if (streamEvent.MessageId is Guid messageId) + { + var doneData = JsonSerializer.Serialize(new { messageId }); + await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct); + } + else + { + var data = JsonSerializer.Serialize(new { content = streamEvent.Content }); + await HttpContext.Response.WriteAsync($"data: {data}\n\n", ct); + } + + await HttpContext.Response.Body.FlushAsync(ct); +} +``` + +The edit Endpoint body is: + +```csharp +var conversationId = Route("ConversationId"); +var messageId = Route("MessageId"); +HttpContext.Response.ContentType = "text/event-stream"; +HttpContext.Response.Headers.CacheControl = "no-cache"; +HttpContext.Response.Headers.Connection = "keep-alive"; + +await foreach (var streamEvent in mediator.CreateStream( + new EditMessageStreamCommand( + conversationId, + messageId, + req.Content), + ct)) +{ + if (streamEvent.MessageId is Guid completedMessageId) + { + var doneData = JsonSerializer.Serialize(new { messageId = completedMessageId }); + await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct); + } + else + { + var data = JsonSerializer.Serialize(new { content = streamEvent.Content }); + await HttpContext.Response.WriteAsync($"data: {data}\n\n", ct); + } + + await HttpContext.Response.Body.FlushAsync(ct); +} +``` + +Remove all EF, entity, cache, AI-agent, and `StringBuilder` imports from both Endpoint files. Keep only `System.Text.Json`, the corresponding Application command namespace, and `MediatR`. + +- [ ] **Step 6: Verify stream behavior and architecture** + +Run: + +```bash +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~ChatStreamOwnershipTests +dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~RagSecurityArchitectureTests +``` + +Expected: both commands PASS; own-user streams yield content chunks followed by one done event, non-owner streams throw NotFound before mutation, and the Endpoint source contains no direct infrastructure dependency. + +- [ ] **Step 7: Commit stream CQRS and ownership** + +```bash +git add src/modules/rag/Application/Chat/Dtos/ChatStreamEvent.cs src/modules/rag/Application/Chat/Commands/StreamMessageCommand.cs src/modules/rag/Application/Chat/Commands/EditMessageStreamCommand.cs src/modules/rag/Application/Chat/Handlers/StreamMessageCommandHandler.cs src/modules/rag/Application/Chat/Handlers/EditMessageStreamCommandHandler.cs src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs tests/BuildFlow.Rag.Tests/ChatStreamOwnershipTests.cs +git commit -m "refactor: move RAG chat streams behind MediatR" +``` + +### Task 6: Remove Consul Secret Value Logging + +**Files:** +- Modify: `src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs:60-68` +- Create: `tests/BuildFlow.Architecture.Tests/ConfigurationSecurityTests.cs` + +- [ ] **Step 1: Write the failing static security test** + +Create `ConfigurationSecurityTests.cs` with repository-root discovery matching `HostArchitectureTests`: + +```csharp +namespace BuildFlow.Architecture.Tests; + +using Xunit; + +public class ConfigurationSecurityTests +{ + [Fact] + public void ConsulProviderShouldNeverLogConfigurationValues() + { + var source = File.ReadAllText(Path.Combine( + FindRepoRoot().FullName, + "src", "BuildFlow.Host", "Configuration", "ConsulConfigurationProvider.cs")); + + Assert.DoesNotContain("{Val}", source); + Assert.DoesNotContain("dict[k]", source); + Assert.DoesNotContain("示例 key:", source); + } + + private static DirectoryInfo FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx"))) return current; + current = current.Parent; + } + + throw new InvalidOperationException("无法定位 BuildFlow.slnx,无法验证配置日志安全边界。"); + } +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~ConfigurationSecurityTests +``` + +Expected: FAIL because the Provider contains `{Val}`, `dict[k]`, and `示例 key:`. + +- [ ] **Step 3: Delete value logging and retain only count/index logs** + +Delete this entire block from `Load()`: + +```csharp +if (dict.Count > 0) +{ + foreach (var k in dict.Keys.Take(3)) + _logger.LogDebug("[Consul] 示例 key: {Key} = {Val}", k, dict[k]?.ToString()?.Substring(0, Math.Min(20, dict[k]?.ToString()?.Length ?? 0))); +} +``` + +Keep the existing load log containing only `{Count}` and `{Index}`, and keep the hot-reload log containing only `{Index}` and `{Count}`. + +- [ ] **Step 4: Verify and commit** + +Run: + +```bash +dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~ConfigurationSecurityTests +rg -n "示例 key:.*Val|dict\[k\]" src/BuildFlow.Host/Configuration +``` + +Expected: test PASS and scan has no output. + +```bash +git add src/BuildFlow.Host/Configuration/ConsulConfigurationProvider.cs tests/BuildFlow.Architecture.Tests/ConfigurationSecurityTests.cs +git commit -m "fix: stop logging Consul configuration values" +``` + +### Task 7: Run The First-Batch Verification Gate + +**Files:** +- Verify only; do not change production behavior during this task. + +- [ ] **Step 1: Run affected module suites** + +Run each command separately and capture its total, failed, and skipped counts: + +```bash +dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore +dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore +dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore +``` + +Expected: all three PASS with 0 failed. + +- [ ] **Step 2: Run the full solution and build** + +```bash +dotnet test BuildFlow.slnx --no-restore +dotnet build BuildFlow.slnx --no-restore +``` + +Expected: full solution PASS with 0 failed; build reports 0 warnings and 0 errors. + +- [ ] **Step 3: Run mandatory static scans** + +```bash +rg -n "admin123" src +rg -n "AllowAnonymous\(\)" src/BuildFlow.Host/Endpoints/Rag/Obsidian +rg -n "示例 key:.*Val|dict\[k\]" src/BuildFlow.Host/Configuration +rg -n "RagDbContext|IAIChatAgent|IChatMessageCache" src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs +rg -n "ReadAllBytesAsync|EnumerateFiles\(.*AllDirectories.*ToList" src/modules/rag/Application/Obsidian +``` + +Expected: all scans produce no output. + +- [ ] **Step 4: Check patch hygiene and repository state** + +```bash +git diff --check +git status --short +``` + +Expected: `git diff --check` has no output. `git status --short` contains no uncommitted first-batch files after the task commits; unrelated pre-existing user files, if any, are reported but left untouched. + +- [ ] **Step 5: Report the gate before starting batch two** + +The execution handoff must state the exact test totals, failed counts, skipped counts, build warning/error counts, static-scan results, and commit hashes for Tasks 0-6. Do not begin the persistent-job batch until every first-batch command above is green.