# 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.