fix: enforce Obsidian ownership and permission

This commit is contained in:
xiangning 2026-07-23 18:02:45 +08:00
parent 1e51e6a965
commit 6980b2ebba
4 changed files with 173 additions and 55 deletions

View File

@ -15,7 +15,7 @@ public class SyncObsidianVaultEndpoint(IMediator mediator)
public override void Configure()
{
Post("/knowledge-bases/{KnowledgeBaseId}/obsidian/sync");
AllowAnonymous();
Permissions("knowledge:write");
}
public override async Task HandleAsync(ObsidianSyncRequest req, CancellationToken ct)
@ -28,9 +28,3 @@ public class SyncObsidianVaultEndpoint(IMediator mediator)
await Send.OkAsync(result, ct);
}
}
/// <summary>
/// Obsidian vault 同步请求校验。
/// 注意:目录存在性属于 IO 层校验,留给 handler 处理(避免 validator 内做磁盘 IO);
/// 此处仅校验非空、长度与枚举取值。
/// </summary>

View File

@ -1,9 +1,10 @@
using BuildFlow.Domain.Entities.Document;
using System.Security.Cryptography;
using MediatR;
using BuildFlow.Application.Obsidian.DTOs;
using BuildFlow.Domain.Enums;
using BuildFlow.Infrastructure.Obsidian;
using BuildFlow.Shared.Domain.Exceptions;
using BuildFlow.Shared.Domain.Common;
using BuildFlow.Infrastructure.Persistence;
using BuildFlow.Application.Obsidian.Commands;
@ -12,24 +13,20 @@ namespace BuildFlow.Application.Obsidian.Handlers;
/// <summary>
/// SyncObsidian同步仓库Command处理器。执行业务校验后持久化。
/// </summary>
public class SyncObsidianVaultCommandHandler(RagDbContext db)
public class SyncObsidianVaultCommandHandler(
RagDbContext db,
ICurrentUserContext userContext,
ObsidianVaultScanner scanner)
: IRequestHandler<SyncObsidianVaultCommand, ObsidianSyncResult>
{
private static readonly string[] ObsidianIgnoreDirs = [".obsidian", ".trash", ".git", "node_modules"];
public async Task<ObsidianSyncResult> Handle(SyncObsidianVaultCommand request, CancellationToken ct)
{
if (!Directory.Exists(request.VaultDirectory))
throw new BusinessException($"Vault 目录不存在:{request.VaultDirectory}");
var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], 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, "知识库不存在");
var vaultRoot = Path.GetFullPath(request.VaultDirectory);
var mdFiles = Directory.EnumerateFiles(vaultRoot, "*.md", SearchOption.AllDirectories)
.Where(f => !ObsidianIgnoreDirs.Any(d => f.Contains(Path.DirectorySeparatorChar + d, StringComparison.OrdinalIgnoreCase)))
.ToList();
// 加载该知识库已有的 Obsidian 文档(VaultPath -> 实体)
var existing = await db.Documents
.Where(d => d.KnowledgeBaseId == kb.Id && d.SourceType == DocumentSourceTypeEnum.Obsidian)
@ -38,62 +35,55 @@ public class SyncObsidianVaultCommandHandler(RagDbContext db)
var added = 0;
var updated = 0;
var skipped = 0;
var totalFiles = 0;
foreach (var file in mdFiles)
await foreach (var file in scanner.ScanAsync(request.VaultDirectory, ct))
{
ct.ThrowIfCancellationRequested();
totalFiles++;
var vaultPath = Path.GetRelativePath(vaultRoot, file).Replace('\\', '/');
var fileName = Path.GetFileName(file);
var fileBytes = await File.ReadAllBytesAsync(file, ct);
var hash = Convert.ToHexString(SHA256.HashData(fileBytes)).ToLowerInvariant();
var fileSize = fileBytes.LongLength;
if (existing.TryGetValue(vaultPath, out var doc))
if (existing.TryGetValue(file.RelativePath, out var doc))
{
// 增量:哈希未变则跳过
if (doc.ContentHash == hash)
if (doc.ContentHash == file.ContentHash)
{
skipped++;
continue;
}
// 变更:更新文件路径与哈希,标记为待重新处理
doc.FilePath = file;
doc.FileName = fileName;
doc.FileSize = fileSize;
doc.ContentHash = hash;
doc.FilePath = file.FullPath;
doc.FileName = file.FileName;
doc.FileSize = file.FileSize;
doc.ContentHash = file.ContentHash;
doc.Status = DocumentStatusEnum.Pending;
doc.VaultPath = vaultPath;
doc.VaultPath = file.RelativePath;
updated++;
continue;
}
else
db.Documents.Add(new DocumentEntity
{
// 新增
db.Documents.Add(new DocumentEntity
{
KnowledgeBaseId = kb.Id,
Title = fileName,
FileName = fileName,
FilePath = file,
FileSize = fileSize,
ContentType = "text/markdown",
Status = DocumentStatusEnum.Pending,
SourceType = DocumentSourceTypeEnum.Obsidian,
VaultPath = vaultPath,
ContentHash = hash,
ChunkingModeOverride = request.ChunkingModeOverride
});
added++;
}
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(
KnowledgeBaseId: kb.Id,
VaultDirectory: vaultRoot,
TotalFiles: mdFiles.Count,
VaultDirectory: Path.GetFullPath(request.VaultDirectory),
TotalFiles: totalFiles,
Added: added,
Updated: updated,
Skipped: skipped,

View File

@ -0,0 +1,38 @@
namespace BuildFlow.Architecture.Tests;
using Xunit;
/// <summary>
/// RAG 高风险入口的权限和 CQRS 架构约束。
/// </summary>
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 安全边界。");
}
}

View File

@ -0,0 +1,96 @@
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;
/// <summary>
/// Obsidian 同步的知识库所有权和增量行为测试。
/// </summary>
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);
}
}