fix: enforce Obsidian ownership and permission
This commit is contained in:
parent
1e51e6a965
commit
6980b2ebba
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 安全边界。");
|
||||
}
|
||||
}
|
||||
96
tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs
Normal file
96
tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user