82 KiB
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<TEvent>,不保留 Endpoint 直连 RagDbContext 的过渡实现。
Tech Stack: .NET 10、EF Core、MediatR、FastEndpoints、FluentValidation、xUnit
File Map
Baseline
- Modify:
tests/BuildFlow.Rag.Tests/AiModelRuntimeConfigResolverTests.cs— 用AddOptions<AiOptions>()注册生产代码需要的IOptionsMonitor<AiOptions>。
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:
dotnet test tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj --no-restore --filter FullyQualifiedName~AiModelRuntimeConfigResolverTests
Expected: FAIL because AiModelRuntimeConfigResolver requests IOptionsMonitor<AiOptions> but the test container only contains IOptions<AiOptions>.
- 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:
services.AddOptions<AiOptions>()
.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:
services.AddOptions<AiOptions>()
.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:
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:
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
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:
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:
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<InvalidOperationException>()
.WithMessage("*密码不能为空*");
}
[Fact]
public async Task WeakPasswordShouldFailClearly()
{
await using var db = PlatformTestDb.CreateDbContext();
var act = () => BootstrapAdminSeeder.SeedAsync(db, ValidOptions("weakpass"));
await act.Should().ThrowAsync<InvalidOperationException>()
.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:
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:
namespace BuildFlow.Platform.Domain.Services;
/// <summary>平台账号统一密码强度规则。</summary>
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:
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:
namespace BuildFlow.Platform.Infrastructure.Configuration;
/// <summary>首次部署管理员初始化配置。</summary>
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:
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;
/// <summary>按部署配置幂等初始化最高权限管理员。</summary>
public static class BootstrapAdminSeeder
{
public static readonly Guid AdminRoleId =
Guid.Parse("20000000-0000-0000-0000-000000000002");
public static async Task<bool> 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:
services.Configure<BootstrapAdminOptions>(
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:
await SafeRunAsync(
async () =>
{
var platformDb = sp.GetRequiredService<PlatformDbContext>();
await PlatformSeedData.SeedAsync(platformDb);
var bootstrapOptions =
sp.GetRequiredService<IOptions<BootstrapAdminOptions>>().Value;
var adminCreated = await BootstrapAdminSeeder.SeedAsync(
platformDb,
bootstrapOptions);
logger.LogInformation(adminCreated
? "Bootstrap admin 已创建"
: "Bootstrap admin 已跳过");
},
logger,
"Platform Seed");
Use this second action for Workflow:
await SafeRunAsync(
async () =>
{
var wfDb = sp.GetRequiredService<WorkflowDbContext>();
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:
"Platform": {
"BootstrapAdmin": {
"Enabled": false,
"Username": "admin",
"Email": "admin@rag.local",
"Password": ""
}
}
Add this example section to appsettings.Secrets.example.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:
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:
rg -n "admin123" src
Expected: no output.
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:
using Xunit;
namespace BuildFlow.Rag.Tests;
[CollectionDefinition("ObsidianVaultScanner", DisableParallelization = true)]
public sealed class ObsidianVaultScannerCollection
{
public const string Name = "ObsidianVaultScanner";
}
Create ObsidianVaultScannerTests.cs:
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<BusinessException>()
.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<BusinessException>()
.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<BusinessException>()
.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<BusinessException>()
.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<BusinessException>()
.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<BusinessException>()
.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<ObsidianVaultScanner>.Instance);
}
private static async Task<List<ObsidianVaultFile>> CollectAsync(
IAsyncEnumerable<ObsidianVaultFile> source)
{
var result = new List<ObsidianVaultFile>();
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:
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:
namespace BuildFlow.Infrastructure.Obsidian;
/// <summary>Obsidian 服务端目录访问与容量限制。</summary>
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:
namespace BuildFlow.Infrastructure.Obsidian;
/// <summary>通过安全边界检查后的 Obsidian Markdown 文件。</summary>
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:
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;
/// <summary>在允许根目录内流式枚举并计算 Obsidian Markdown 文件元数据。</summary>
public sealed class ObsidianVaultScanner(
IOptions<ObsidianOptions> options,
ILogger<ObsidianVaultScanner> logger)
{
private static readonly HashSet<string> IgnoredDirectories =
new(StringComparer.OrdinalIgnoreCase) { ".obsidian", ".trash", ".git", "node_modules" };
private readonly ObsidianOptions _options = options.Value;
public async IAsyncEnumerable<ObsidianVaultFile> ScanAsync(
string requestedDirectory,
[EnumeratorCancellation] CancellationToken ct = default)
{
var (vaultRoot, allowedRoot) = ResolveVaultRoot(requestedDirectory);
var pending = new Stack<(DirectoryInfo Directory, string RelativeDirectory)>();
var visited = new HashSet<string>(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<FileSystemInfo> 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:
services.AddOptions<ObsidianOptions>()
.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<ObsidianVaultScanner>();
Add the namespace import and this appsettings.json section. An empty root list deliberately disables server-directory access until deployment configuration opts in:
"Rag": {
"Obsidian": {
"AllowedRoots": [],
"MaxFiles": 10000,
"MaxFileBytes": 10485760,
"MaxTotalBytes": 536870912
}
}
- Step 6: Verify scanner tests pass
Run:
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
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:
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<NotFoundException>()
.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<RagDbContext>()
.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<ObsidianVaultScanner>.Instance);
}
public void Dispose()
{
if (Directory.Exists(_allowedRoot)) Directory.Delete(_allowedRoot, recursive: true);
}
}
Create RagSecurityArchitectureTests.cs:
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:
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:
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:
public class SyncObsidianVaultCommandHandler(
RagDbContext db,
ICurrentUserContext userContext,
ObsidianVaultScanner scanner)
: IRequestHandler<SyncObsidianVaultCommand, ObsidianSyncResult>
{
public async Task<ObsidianSyncResult> 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:
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:
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
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:
new SendMessageCommandHandler(db, chatAgent, cache, userContext)
new GetConversationDetailQueryHandler(db, cache, userContext)
new DeleteConversationCommandHandler(db, cache, userContext)
Add this send test to ChatEndToEndTests.cs:
[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<NotFoundException>()
.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:
[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<NotFoundException>()
.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<NotFoundException>()
.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<NotFoundException>()
.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:
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:
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:
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:
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
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:
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<NotFoundException>()
.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<NotFoundException>()
.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<RagDbContext>()
.UseInMemoryDatabase($"chat-stream-{Guid.NewGuid():N}")
.AddInterceptors(new AuditInterceptor(userContext))
.Options;
return new RagDbContext(options);
}
private static async Task<Guid> 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<List<T>> CollectAsync<T>(IAsyncEnumerable<T> source)
{
var result = new List<T>();
await foreach (var item in source) result.Add(item);
return result;
}
}
Extend RagSecurityArchitectureTests:
[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:
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:
namespace BuildFlow.Application.Chat.DTOs;
/// <summary>聊天流的内容片段或完成事件。</summary>
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:
using BuildFlow.Application.Chat.DTOs;
using MediatR;
namespace BuildFlow.Application.Chat.Commands;
/// <summary>向当前用户拥有的会话发送流式消息。</summary>
public sealed record StreamMessageCommand(Guid ConversationId, string Content)
: IStreamRequest<ChatStreamEvent>;
Create EditMessageStreamCommand.cs:
using BuildFlow.Application.Chat.DTOs;
using MediatR;
namespace BuildFlow.Application.Chat.Commands;
/// <summary>编辑当前用户会话中的提问并重新流式生成回复。</summary>
public sealed record EditMessageStreamCommand(
Guid ConversationId,
Guid MessageId,
string Content) : IStreamRequest<ChatStreamEvent>;
- Step 4: Implement streaming handlers with ownership as the first database operation
Create StreamMessageCommandHandler.cs:
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;
/// <summary>向当前用户会话发送消息并产出流式回复事件。</summary>
public sealed class StreamMessageCommandHandler(
RagDbContext db,
IAIChatAgent chatAgent,
IChatMessageCache cache,
ICurrentUserContext userContext)
: IStreamRequestHandler<StreamMessageCommand, ChatStreamEvent>
{
public async IAsyncEnumerable<ChatStreamEvent> 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:
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;
/// <summary>编辑当前用户的提问,截断后续消息并重新流式生成回复。</summary>
public sealed class EditMessageStreamCommandHandler(
RagDbContext db,
IAIChatAgent chatAgent,
IChatMessageCache cache,
ICurrentUserContext userContext)
: IStreamRequestHandler<EditMessageStreamCommand, ChatStreamEvent>
{
public async IAsyncEnumerable<ChatStreamEvent> 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:
var conversationId = Route<Guid>("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:
var conversationId = Route<Guid>("ConversationId");
var messageId = Route<Guid>("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:
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
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:
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:
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():
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:
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.
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:
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
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
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
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.