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()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/knowledge-bases/{KnowledgeBaseId}/obsidian/sync");
|
Post("/knowledge-bases/{KnowledgeBaseId}/obsidian/sync");
|
||||||
AllowAnonymous();
|
Permissions("knowledge:write");
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(ObsidianSyncRequest req, CancellationToken ct)
|
public override async Task HandleAsync(ObsidianSyncRequest req, CancellationToken ct)
|
||||||
@ -28,9 +28,3 @@ public class SyncObsidianVaultEndpoint(IMediator mediator)
|
|||||||
await Send.OkAsync(result, ct);
|
await Send.OkAsync(result, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Obsidian vault 同步请求校验。
|
|
||||||
/// 注意:目录存在性属于 IO 层校验,留给 handler 处理(避免 validator 内做磁盘 IO);
|
|
||||||
/// 此处仅校验非空、长度与枚举取值。
|
|
||||||
/// </summary>
|
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
using BuildFlow.Domain.Entities.Document;
|
using BuildFlow.Domain.Entities.Document;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using BuildFlow.Application.Obsidian.DTOs;
|
using BuildFlow.Application.Obsidian.DTOs;
|
||||||
using BuildFlow.Domain.Enums;
|
using BuildFlow.Domain.Enums;
|
||||||
|
using BuildFlow.Infrastructure.Obsidian;
|
||||||
using BuildFlow.Shared.Domain.Exceptions;
|
using BuildFlow.Shared.Domain.Exceptions;
|
||||||
|
using BuildFlow.Shared.Domain.Common;
|
||||||
using BuildFlow.Infrastructure.Persistence;
|
using BuildFlow.Infrastructure.Persistence;
|
||||||
using BuildFlow.Application.Obsidian.Commands;
|
using BuildFlow.Application.Obsidian.Commands;
|
||||||
|
|
||||||
@ -12,24 +13,20 @@ namespace BuildFlow.Application.Obsidian.Handlers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// SyncObsidian同步仓库Command处理器。执行业务校验后持久化。
|
/// SyncObsidian同步仓库Command处理器。执行业务校验后持久化。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SyncObsidianVaultCommandHandler(RagDbContext db)
|
public class SyncObsidianVaultCommandHandler(
|
||||||
|
RagDbContext db,
|
||||||
|
ICurrentUserContext userContext,
|
||||||
|
ObsidianVaultScanner scanner)
|
||||||
: IRequestHandler<SyncObsidianVaultCommand, ObsidianSyncResult>
|
: IRequestHandler<SyncObsidianVaultCommand, ObsidianSyncResult>
|
||||||
{
|
{
|
||||||
private static readonly string[] ObsidianIgnoreDirs = [".obsidian", ".trash", ".git", "node_modules"];
|
|
||||||
|
|
||||||
public async Task<ObsidianSyncResult> Handle(SyncObsidianVaultCommand request, CancellationToken ct)
|
public async Task<ObsidianSyncResult> Handle(SyncObsidianVaultCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(request.VaultDirectory))
|
var userId = userContext.GetRequiredUserId();
|
||||||
throw new BusinessException($"Vault 目录不存在:{request.VaultDirectory}");
|
var kb = await db.KnowledgeBases.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == request.KnowledgeBaseId && x.CreatedBy == userId,
|
||||||
var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct)
|
ct)
|
||||||
?? throw new NotFoundException(ErrorCode.NotFound, "知识库不存在");
|
?? 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 -> 实体)
|
// 加载该知识库已有的 Obsidian 文档(VaultPath -> 实体)
|
||||||
var existing = await db.Documents
|
var existing = await db.Documents
|
||||||
.Where(d => d.KnowledgeBaseId == kb.Id && d.SourceType == DocumentSourceTypeEnum.Obsidian)
|
.Where(d => d.KnowledgeBaseId == kb.Id && d.SourceType == DocumentSourceTypeEnum.Obsidian)
|
||||||
@ -38,62 +35,55 @@ public class SyncObsidianVaultCommandHandler(RagDbContext db)
|
|||||||
var added = 0;
|
var added = 0;
|
||||||
var updated = 0;
|
var updated = 0;
|
||||||
var skipped = 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('\\', '/');
|
if (existing.TryGetValue(file.RelativePath, out var doc))
|
||||||
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 (doc.ContentHash == hash)
|
if (doc.ContentHash == file.ContentHash)
|
||||||
{
|
{
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 变更:更新文件路径与哈希,标记为待重新处理
|
// 变更:更新文件路径与哈希,标记为待重新处理
|
||||||
doc.FilePath = file;
|
doc.FilePath = file.FullPath;
|
||||||
doc.FileName = fileName;
|
doc.FileName = file.FileName;
|
||||||
doc.FileSize = fileSize;
|
doc.FileSize = file.FileSize;
|
||||||
doc.ContentHash = hash;
|
doc.ContentHash = file.ContentHash;
|
||||||
doc.Status = DocumentStatusEnum.Pending;
|
doc.Status = DocumentStatusEnum.Pending;
|
||||||
doc.VaultPath = vaultPath;
|
doc.VaultPath = file.RelativePath;
|
||||||
updated++;
|
updated++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
db.Documents.Add(new DocumentEntity
|
||||||
{
|
{
|
||||||
// 新增
|
KnowledgeBaseId = kb.Id,
|
||||||
db.Documents.Add(new DocumentEntity
|
Title = file.FileName,
|
||||||
{
|
FileName = file.FileName,
|
||||||
KnowledgeBaseId = kb.Id,
|
FilePath = file.FullPath,
|
||||||
Title = fileName,
|
FileSize = file.FileSize,
|
||||||
FileName = fileName,
|
ContentType = "text/markdown",
|
||||||
FilePath = file,
|
Status = DocumentStatusEnum.Pending,
|
||||||
FileSize = fileSize,
|
SourceType = DocumentSourceTypeEnum.Obsidian,
|
||||||
ContentType = "text/markdown",
|
VaultPath = file.RelativePath,
|
||||||
Status = DocumentStatusEnum.Pending,
|
ContentHash = file.ContentHash,
|
||||||
SourceType = DocumentSourceTypeEnum.Obsidian,
|
ChunkingModeOverride = request.ChunkingModeOverride,
|
||||||
VaultPath = vaultPath,
|
});
|
||||||
ContentHash = hash,
|
added++;
|
||||||
ChunkingModeOverride = request.ChunkingModeOverride
|
|
||||||
});
|
|
||||||
added++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return new ObsidianSyncResult(
|
return new ObsidianSyncResult(
|
||||||
KnowledgeBaseId: kb.Id,
|
KnowledgeBaseId: kb.Id,
|
||||||
VaultDirectory: vaultRoot,
|
VaultDirectory: Path.GetFullPath(request.VaultDirectory),
|
||||||
TotalFiles: mdFiles.Count,
|
TotalFiles: totalFiles,
|
||||||
Added: added,
|
Added: added,
|
||||||
Updated: updated,
|
Updated: updated,
|
||||||
Skipped: skipped,
|
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