Laravel知识库接入PDF:文件能检索,不代表当前用户有权查看

知识库能返回某份PDF里的内容,不代表当前用户应该看到。只给原文件下载接口加权限,检索已经把文字送进模型,也来不及了。
需要在检索结果进入上下文之前检查访问范围,再在引用和下载时重新检查。
解析时保留应用文档标识
ComPDF的全文解析结果提供页级内容,但租户ID、用户权限和业务版本由应用管理。写入片段时保留文档ID、版本、页码和所属范围,不能只保存文本。
检索和原文访问使用同一规则
Laravel可以定义Policy或Gate。下面的教学规则只允许活跃用户访问同租户文档:
Gate::define('read-pdf', function ($user, $document) {
return $user->active &&
$user->tenant_id === $document->tenant_id;
});
在检索服务支持可靠权限过滤时,查询阶段先过滤;拿到结果后再次核对,再送给模型。若先把所有内容送进模型,再隐藏无权查看的引用,正文仍可能泄露内容。
本地跑一遍六项授权检查
下面是完整离线测试。需要PHP 8.4.1以上及Laravel正常扩展;新建空目录,保存以下composer.json,安装依赖并建立框架缓存目录。它不连接向量数据库,不启动登录服务,不需要API密钥。
{"require":{"php":"^8.4.1","laravel/framework":"12.69.3"}}
composer install
mkdir -p bootstrap/cache storage/framework/views storage/logs
将下面代码保存为policy-check.php,运行php policy-check.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Gate;
use Illuminate\Http\Request;
require __DIR__.'/vendor/autoload.php';
try {
$app=Application::configure(basePath: __DIR__)->create();
$app->make(Kernel::class)->bootstrap();
$docs=[1=>(object)['id'=>1,'tenant_id'=>10,'version'=>'v1','text'=>'教学PDF A'],2=>(object)['id'=>2,'tenant_id'=>20,'version'=>'v1','text'=>'教学PDF B']];
$alice=(object)['id'=>1,'tenant_id'=>10,'active'=>true];
Gate::define('read-pdf',fn($u,$d)=>$u->active && $u->tenant_id===$d->tenant_id);
// Recheck at retrieval time, before sending context to any model.
function retrieveFor($user,array $docs):array {
return array_values(array_filter($docs,fn($d)=>Gate::forUser($user)->allows('read-pdf',$d)));
}
// The user is injected only for this offline test. A real app must authenticate requests.
$app['router']->get('/documents/{id}',function(Request $request,$id)use($docs){
$d=$docs[(int)$id]??null;
if(!$d)return response('Not found',404);
if(Gate::forUser($request->user())->denies('read-pdf',$d))return response('Forbidden',403);
return response()->json(['id'=>$d->id,'version'=>$d->version]);
});
function fetchDocument($app,$user,$id):int {
$r=Request::create('/documents/'.$id);
$app->instance('request',$r);$r->setUserResolver(fn()=>$user);
return $app['router']->dispatch($r)->getStatusCode();
}
$checks=[
'retrieval_excludes_other_tenant'=>array_map(fn($d)=>$d->id,retrieveFor($alice,$docs))===[1],
'own_document_allowed'=>fetchDocument($app,$alice,1)===200,
'cross_tenant_document_denied'=>fetchDocument($app,$alice,2)===403,
'unknown_document_404'=>fetchDocument($app,$alice,9)===404,
];
$alice->active=false;
$checks['revoked_retrieval_empty']=retrieveFor($alice,$docs)===[];
$checks['revoked_old_link_denied']=fetchDocument($app,$alice,1)===403;
if(in_array(false,$checks,true))throw new RuntimeException('Authorization test failed: '.json_encode($checks));
echo json_encode(['php'=>PHP_VERSION,'laravel'=>$app->version(),'checks'=>$checks],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;
} catch (Throwable $e) { fwrite(STDERR, get_class($e).': '.$e->getMessage().PHP_EOL); exit(1); }
这里把两个候选文档放在内存中,使用同一Gate分别检查候选列表和原文路由。setUserResolver只注入教学用户,不能复制到对外接口替代正常认证。实际路由应接入应用的认证中间件,再取得当前用户。
预期六项检查均为true,原文状态依次是:本租户200、跨租户403、不存在404、用户撤回后403。测试不会生成或下载真实业务文件。

图:候选文档授权分支与撤权后的原文访问;依据Laravel测试整理,不是ComPDF内置权限功能
片段里到底要存哪些字段
解析结果的页码与应用权限不是同一层。一个片段至少要能追溯到:
{"document_id":1,"version":"v1","page":1,"tenant_id":10,"text":"本页解析后的片段"}
这是应用组织片段的结构示例,不是接口原样返回。tenant_id由服务端读取文档归属,不能直接信任浏览器提交的值。页码用于回到原文件,文档版本用于避免把更新后的正文配上旧引用。
检索若支持租户过滤,先在查询条件限制范围,再用同一授权规则复核候选;候选不能先送进模型再筛。过滤后数量不足,应接受少量结果或继续在授权范围内查找,不能补入其他租户数据。应用层过滤也不能替代检索存储层的隔离设计。
权限撤回后,旧引用也要失效
每次读取原文件时重新授权,不能只在生成引用时检查一次。私有对象存储不要发永久公开地址;短时签名地址也要考虑有效期内权限撤回的处理方式。
缓存答案、检索结果和导出文件也要按访问范围设计。缓存键不能只有问题文本,还需要租户、用户或权限范围及文档版本。权限变化后要使相关缓存失效;否则原文接口拒绝了,旧答案仍可能显示敏感内容。
这些要求没有在本例中做端到端验证。实际验收应包含:撤回用户后再发同一问题、访问旧引用、下载旧导出、复用缓存。只修改页面按钮,不会撤回已经发出的内容。
把验证范围写清楚
本次PHP 8.4.27、Laravel 12.69.3的6项检查通过:检索候选排除其他租户、本租户原文允许、跨租户原文拒绝、未知文档404、撤回后候选为空与旧链接拒绝。实际向量检索过滤和缓存隔离尚需在所用服务中验收,不应由Gate测试推断整套RAG系统安全。
在RAG文档处理工作流中,ComPDF承担解析,权限与检索服务共同决定哪些结果可以进入上下文,两部分都不能省略。
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu
推荐文章: