2215 lines
82 KiB
Markdown
2215 lines
82 KiB
Markdown
# 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```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<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:
|
||
|
||
```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;
|
||
|
||
/// <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:
|
||
|
||
```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;
|
||
|
||
/// <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`:
|
||
|
||
```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;
|
||
|
||
/// <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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```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<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:
|
||
|
||
```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;
|
||
|
||
/// <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`:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```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;
|
||
|
||
/// <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`:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```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<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`:
|
||
|
||
```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<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:
|
||
|
||
```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<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`:
|
||
|
||
```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<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:
|
||
|
||
```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<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`:
|
||
|
||
```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;
|
||
|
||
/// <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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```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;
|
||
|
||
/// <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`:
|
||
|
||
```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;
|
||
|
||
/// <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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```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.
|