Skip to content

feat(security): 让用户级 export 权限轴真正在服务端生效 (#3544) - #3709

Merged
os-zhuang merged 3 commits into
mainfrom
claude/user-level-export-permissions-p1jm7y
Jul 27, 2026
Merged

feat(security): 让用户级 export 权限轴真正在服务端生效 (#3544)#3709
os-zhuang merged 3 commits into
mainfrom
claude/user-level-export-permissions-p1jm7y

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

背景:#3553 只做了一半

#3553 已经把 #3544 的清单打勾了 —— spec 加了 allowExport 位,/me/permissions 按它计算 userExportAllowed 并下发 apiOperations,前端据此隐藏 Export 按钮。但服务端一行都没有拦

因为 export ⊆ list:REST 导出路由 GET /data/:object/export 是通过 findData 流式读的,引擎安全中间件看到的是一次普通 find,只按 allowRead 放行。全仓没有任何代码路径读过 allowExport。所以今天:

# 权限集里 deal.allowExport = false —— 页面上 Export 按钮消失了
curl -H "Cookie: <该用户的会话>" \
  'http://localhost:3000/api/v1/data/deal/export?format=csv&limit=50000'
# → 200,整表 CSV 照样流出来

Issue 里写的「接上后,不再是『能 list 即能导出』」并没有成立 —— 少的正是 AGENTS.md 铁律 #10 说的那一层:declared ≠ enforced,case 标签不是执法,要看调用点。这个 PR 补的就是调用点。

改动

  • plugin-security checkObjectPermission('export', …) 成为真正的判定:读权限 ∧ 未被显式拒绝
    allowExport 刻意OPERATION_TO_PERMISSION —— 那张表的语义是「该位必须为真」,套上去会让所有在本轴之前写的权限集瞬间失去导出。新导出的 resolveUserExportAllowed() 做三态折叠(true > false > 未设),与 /me/permissions 的对象合并逐例一致。
  • spec ISecurityService 新增 canExport(object, context) —— 引擎中间件之外的批量出数据的门,在读之前必须问的那个问题。fail CLOSED;isSystem 与「解析出零个权限集」放行,与中间件的 if (permissionSets.length > 0) 保持一致。
  • rest 导出路由在取第一个 chunk 之前返回 403 EXPORT_NOT_PERMITTED。与对象级 405 分开、且 405 仍在前:405 = 这个对象不暴露 export,403 = 你这个人不能用。
  • plugin-hono-server 注解在对象条目没有自己的 allowExport 时回落到 '*' 条目的位 —— 服务端 resolveObjectPermission 本来就会回落到通配符,不跟上就会出现「按钮给你、请求 403」的客户端/服务端分歧(和 foldWildcardSuperUser 要解决的是同一类问题)。
  • docs 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(接口成员为必选,与 #3547getReadableFields 的做法一致);消费方仍然 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)通过。

packages/rest/src/package-routes.ts 上有两条 string | string[] 的 tsc 报错,是本分支之前就有的、与本次改动无关(该文件未被触碰,tsup DTS 构建通过)。

一个刻意留在范围外的发现 → #3710

plugin-reports 的定时报表会把 CSV 作为附件邮件发出(report-service.ts dispatchDue),它走的是报表所有者上下文 + RLS,同样不经过本轴。也就是说 allowExport:false 的用户仍可以建一张报表、定时把 CSV 发给自己。报表有自己的归属/共享模型(而且跨对象报表该判定哪个对象、判定 owner 还是建 schedule 的人,都得先定调),把导出轴延伸过去是另一件事,按铁律 #10 开了 #3710,没有在这里扩范围。

关联:#3544(本 issue)、#3553(前半程)、#3391#3498#3710(follow-up)、objectui#2823。

`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
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 3:29pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/plugin-hono-server, @objectstack/plugin-security, @objectstack/rest, @objectstack/spec.

108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-hono-server)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security, @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-hono-server, @objectstack/plugin-security, @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-hono-server, @objectstack/plugin-security, @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-hono-server, @objectstack/plugin-security, @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

claude added 2 commits July 27, 2026 15:26
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
os-zhuang marked this pull request as ready for review July 27, 2026 15:44
@os-zhuang
os-zhuang merged commit 9613396 into main Jul 27, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/user-level-export-permissions-p1jm7y branch July 27, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants