feat(security): 让用户级 export 权限轴真正在服务端生效 (#3544) - #3709
Merged
Conversation
`allowExport` shipped as a spec bit plus a `/me/permissions` annotation,
which hid the client's Export button and nothing more. Because
`export ⊆ list`, the REST export route streams through `findData` and the
engine middleware sees an ordinary `find` gated by `allowRead` — no code
path ever read the bit, so a caller holding `allowExport: false` could
still curl `/api/v1/data/:object/export` and drain the whole table.
Declared, not enforced.
- plugin-security: `checkObjectPermission('export', …)` becomes a real
decision — read granted AND not explicitly denied. `allowExport` stays
out of OPERATION_TO_PERMISSION on purpose (that map means "the bit must
be truthy", which would deny export to every set authored before the
axis existed). New exported `resolveUserExportAllowed()` folds the
tri-state across sets exactly as the `/me/permissions` merge does.
- spec: `ISecurityService.canExport(object, context)` — the question a
bulk-egress door outside the engine middleware must ask before reading.
Fails closed; `isSystem` and an empty set resolution bypass, mirroring
the middleware.
- rest: `GET /data/:object/export` answers 403 EXPORT_NOT_PERMITTED
before the first chunk is fetched, distinct from the object-level 405
that still runs first (405 = the object exposes no export, 403 = this
caller may not use it).
- plugin-hono-server: the annotation falls back to the `'*'` entry's
export bit, matching the evaluator's own wildcard fallback, so a set
denying export wholesale no longer offers a button the server refuses.
Backward-compatible: an unset bit still inherits read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDbwCy9Jrc1chhR2vnAUos
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
The hand-written object-permission-bits table never picked up `allowExport` (#3553 only regenerated the auto-gen reference). Now that the bit is actually enforced server-side, document what it is: the tri-state, the most-permissive merge with an explicit deny in the middle, that it narrows read and never widens it, and that the 403 and the hidden client button are one decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDbwCy9Jrc1chhR2vnAUos
The stub that "implements the full surface" needs the new member, and the fail-closed posture is worth pinning next to getReadFilter's: `undefined` there means no restriction, `false` here means denied — a consumer that reads them the same way leaks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDbwCy9Jrc1chhR2vnAUos
os-zhuang
marked this pull request as ready for review
July 27, 2026 15:44
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景:#3553 只做了一半
#3553 已经把 #3544 的清单打勾了 —— spec 加了
allowExport位,/me/permissions按它计算userExportAllowed并下发apiOperations,前端据此隐藏 Export 按钮。但服务端一行都没有拦。因为
export ⊆ list:REST 导出路由GET /data/:object/export是通过findData流式读的,引擎安全中间件看到的是一次普通find,只按allowRead放行。全仓没有任何代码路径读过allowExport。所以今天:Issue 里写的「接上后,不再是『能 list 即能导出』」并没有成立 —— 少的正是 AGENTS.md 铁律 #10 说的那一层:
declared ≠ enforced,case标签不是执法,要看调用点。这个 PR 补的就是调用点。改动
checkObjectPermission('export', …)成为真正的判定:读权限 ∧ 未被显式拒绝。allowExport刻意不进OPERATION_TO_PERMISSION—— 那张表的语义是「该位必须为真」,套上去会让所有在本轴之前写的权限集瞬间失去导出。新导出的resolveUserExportAllowed()做三态折叠(true>false> 未设),与/me/permissions的对象合并逐例一致。ISecurityService新增canExport(object, context)—— 引擎中间件之外的批量出数据的门,在读之前必须问的那个问题。fail CLOSED;isSystem与「解析出零个权限集」放行,与中间件的if (permissionSets.length > 0)保持一致。EXPORT_NOT_PERMITTED。与对象级 405 分开、且 405 仍在前:405 = 这个对象不暴露 export,403 = 你这个人不能用。allowExport时回落到'*'条目的位 —— 服务端resolveObjectPermission本来就会回落到通配符,不跟上就会出现「按钮给你、请求 403」的客户端/服务端分歧(和foldWildcardSuperUser要解决的是同一类问题)。content/docs/permissions/permission-sets.mdx的「对象权限位」表一直没有allowExport(feat(spec,hono): user-level export permission axis (#3544) #3553 只重生成了 auto-gen 的 reference)。既然现在真的会执法了,把三态、跨权限集的合并规则、「只收窄不放宽」、以及「403 与前端隐藏按钮是同一个判定」写清楚。兼容性
allowExport仍是无默认值的 opt-out:未设 = 继承 read,现有权限集行为逐字不变。只有显式写了allowExport: false的权限集会变 —— 而且现在是在服务端变,这正是本 issue 的目的。ISecurityService的实现方需要补canExport(接口成员为必选,与 #3547 加getReadableFields的做法一致);消费方仍然 feature-detect,部分实现是降级而不是抛错。测试
新增 25 条,全部针对本轴:
plugin-security/src/export-permission-axis.test.ts(18) —— 三态折叠(含'*'通配符回落、ADR-0066 D2 private 对象不吃非超级用户通配符)、export ⊆ list(给了allowExport:true但没有读权限 → 仍然拒)、超级用户通配符不能绕过显式的按对象 export 拒绝、拒绝 export 时 read 保持授予(Salesforce "Export Reports" 形状)。rest/src/rest-export-permission-gate.test.ts(7) —— 403 的同时断言findData从未被调用(拒绝不能先漏出第一个 chunk);无 security 服务 → 放行;服务在但没有canExport→ 放行;canExport抛错 → 403(fail closed);对象级 405 仍然优先。回归:spec 6687 / plugin-security 626 / rest 408 / plugin-hono-server 114 全绿;三个包
tsc --noEmit干净;check:api-surface(surface unchanged)、check:docs(250 个生成文件 in sync)、check:doc-authoring(213 files clean)通过。一个刻意留在范围外的发现 → #3710
plugin-reports的定时报表会把 CSV 作为附件邮件发出(report-service.tsdispatchDue),它走的是报表所有者上下文 + RLS,同样不经过本轴。也就是说allowExport:false的用户仍可以建一张报表、定时把 CSV 发给自己。报表有自己的归属/共享模型(而且跨对象报表该判定哪个对象、判定 owner 还是建 schedule 的人,都得先定调),把导出轴延伸过去是另一件事,按铁律 #10 开了 #3710,没有在这里扩范围。关联:#3544(本 issue)、#3553(前半程)、#3391、#3498、#3710(follow-up)、objectui#2823。