Skip to content

实现 ADR-0106:元数据面 FLS——object schema 按调用者掩码(#3661 ③ 落地) #3682

Description

@os-zhuang

跟踪 ADR-0106(#3678 已合并)的实现。决策背景与完整论证见 ADR 正文及 #3661;客户端侧 ①② 已由 objectstack-ai/objectui#2866 承担。

实现清单(按 ADR 决策点)

  • D1/D2 — 分发层投影:object schema 读按 security.getReadableFields(object, ctx) 投影 fields,掩码字段整体删除。协议层 getMetaItem/getMetaItems 签名不动。落点:
    • packages/rest/src/rest-server.ts — GET /meta/object/:name 的 cached 与非 cached 两条路径
    • packages/rest/src/rest-server.ts — GET /meta/object 列表读(逐 item 投影)
    • packages/runtime/src/domains/meta.ts — /metadata catch-all 的 object 分支(protocol 与 registry 两条查找路径)
  • D3 — mask-after-cache + 指纹 ETag:共享缓存继续每对象存一份全量;取出后掩码;调用者对该对象的 denied-set 稳定哈希(无限制调用者为空)折入 ETag。顺序约束:fetch → mask(或 error)→ send,缓存全量体不得有任何绕过掩码上线的路径。实现受阻时可先落 doc/book 式 bypass(记录为过渡,非终态)。
  • D4 — 豁免:isSystem(getReadableFields 已有)+ 平台 admin(复用 app 过滤的 systemPermissions 判定)。豁免是 caller 属性,不是路由属性。
  • D5(4) — 出口审计:排查其余 schema-bearing 端点(OData $metadata/describe 类、client SDK describe);未覆盖的按 Prime Directive chore: version packages #10 开 issue。
  • D6 — 三档失败姿态:无 security 服务 → 放行;undefined → 放行 + 结构化 warn + 指标 + Cache-Control: private, no-store(不发共享 ETag);评估抛异常 → 5xx(绝不放出未掩码体,绝不返回空 fields 的 200)。
  • D7 — 零权限集调用者:接 /auth/me/permissions 同款 fallback set 解析(security.fallbackPermissionSet,默认 member_default),落 plugin-security。
  • D8 — 配置逃生舱:metadata.maskObjectFields(命名实现时定),默认开,随当前 major 发布。
  • 测试:掩码正确性(含 formula/options/requiredPermissions 随字段整体消失)、同 cohort 304 语义与权限变更后指纹失效、admin/isSystem 豁免、guest fallback、三档失败姿态(尤其「抛异常路径不泄漏缓存体」)。
  • 文档:部署文档补 CDN/代理缓存指引(指纹 ETag 与共享缓存的交互)。
  • 收尾:实现合入后将 ADR-0106 Status 行 Proposed → Accepted。

验收标准

对一个持有受限权限集的已认证调用者:任一 /meta//metadata 出口返回的 object schema 中,其不可读字段(含 readable:false 与缺失 requiredPermissions 两种成因)完全不存在;无限制调用者的响应与实现前字节一致;权限变更后旧 ETag 不再命中 304。

关联:#3661(③)、#3678(ADR)、objectstack-ai/objectui#2866(①②)、#3547(getReadableFields)、ADR-0049 / ADR-0066 D3 / ADR-0090。

Activity

  1. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    存量裁决轮(维护者 2026-08-06 委托,session_01LeEfA7CFwbJb7JJmXm2KM3):裁定入队。ADR-0106 已合入而实现零落地(rest/runtime 的 meta 路径对 getReadableFields 零调用),受限调用者仍能从 /meta 拿到不可读字段全量 schema——已裁决未执行的信息披露缺口,restore-invariant 类。派发时注意与 #4513 同触 meta 读路径,需串行或协调。维护者可否决。


    Generated by Claude Code

  2. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    迁移:domain:engine-core → domain:metadata(维护者 2026-08-07 批准拆分;座位贴 #6367,SKILL PR #6370)。落点证据:GET /meta/object 按调用者掩码,mask-after-cache —— /meta 服务路径(rest-server.ts + runtime/domains/meta.ts)。全量分类审计 49 卡三判,本卡 MOVE/high。分诊会话:session_01BickTBKm2JYSNnrtPT8ysa。


    Generated by Claude Code

  3. self-assigned this
    on Aug 8, 2026
  4. baozhoutao commented on Aug 8, 2026

    @baozhoutao
    Contributor

    Claim: PM loop round 3 (domain:metadata seat, sticker #6367)
    Session (PM): session_01KDU3qAuJyajAQm3GkUXdfA
    Branch: claude/issue-3682-adr-0106-meta-fls
    Execution: mode:cloud single-container card (size L per the 2026-08-07 ruling on heavy cards) — its own cloud session with a fresh clone; the dev session's identity will appear on its PR and report comment.
    Domain: domain:metadata (triage MOVE verdict 2026-08-07: mask-after-cache on the /meta serving path)
    File surface: packages/rest/src/rest-server.ts (GET /meta object single — cached + non-cached — and list reads: D1/D2 projection, D3 mask-after-cache + fingerprint ETag), packages/runtime/src/domains/meta.ts (/metadata catch-all object branch), packages/plugins/plugin-security (D7 fallback permission set), tests, deployment-docs CDN note, ADR-0106 status flip. Stop on breach; explain in the report.
    Serial constraints cleared: predecessor on this seam #4513 → PR #6561 MERGED 2026-08-08 05:19Z (this card's byte-identical-baseline clause now measures against a consistent read surface); #6215 / #5309 in flight this seat — metadata-protocol/metadata packages, no file overlap; domain:cli lane currently has no in-flight claim on rest-server.ts (its round-3 card is packages/client) — its queued pool cards #6349/#5582/#6306 do target this file, courtesy notice posted on the cli seat sticker #6024 so sequencing is visible before they dispatch.
    Container class: L — mode:cloud.


    Generated by Claude Code

  5. baozhoutao commented on Aug 8, 2026

    @baozhoutao
    Contributor
    {
      "issue": "objectstack-ai/objectstack#3682",
      "status": "done",
      "branch": "claude/issue-3682-adr-0106-meta-fls",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/6612",
      "premise_still_valid": true,
      "premise_evidence": "Re-verified against origin/main (1a53a02) before writing code: `grep -rn getReadableFields packages/rest/src packages/runtime/src` returned exactly one non-test call site — the #3547 export-column projection at rest-server.ts:6169. Zero on any /meta or /metadata read path. The 2026-08-06 adjudication's 'implementation is at ZERO' held.",
      "summary": "ADR-0106 D1-D8 implemented. One shared projection lives in @objectstack/metadata-core (object-schema-fls.ts, following #6561's per-exit shared-normalizer shape) and is called at every object-schema exit: REST single cached, single uncached, ?layers=true (code/overlay/effective), compound-name, and list; runtime /metadata object branch (protocol + registry + last-ditch), /metadata/objects list (protocol + registry), and the legacy one-segment read. D3 masks after the cache and folds a denied-set FNV-1a fingerprint into the ETag (empty for unrestricted callers -> byte-identical); when a projection applies, If-None-Match is NOT forwarded to getMetaItemCached (its validator hashes the unfiltered document) and the comparison happens in the REST layer against the fingerprinted ETag. D4 exempts isSystem + studio.access/setup.access, short-circuiting before the security call. D6 three tiers, with 503 FIELD_VISIBILITY_UNRESOLVED on a throw and on an all-denied projection. D7 adds SecurityPlugin.getMetadataReadableFields (fallback-set resolution on zero permission sets); getReadableFields unchanged. D8 ships metadata.maskObjectFields plus OS_ALLOW_UNMASKED_OBJECT_METADATA. Docs: env-var entry + a new CDN/reverse-proxy section in production-readiness. ADR-0106 Status flipped Proposed -> Accepted, with two adr-anchors entries. Changeset: minor on metadata-core, rest, runtime, plugin-security. metadata-protocol signatures untouched; objectui untouched; packages/spec untouched; content/docs/releases untouched; the metadata WRITE path untouched.",
      "tests": {
        "shared_case_table": "packages/metadata-core/src/object-schema-fls-contract.ts, exported via @objectstack/metadata-core/testing. 11 cases drive every exit in both dispatch packages, so a future exit that forgets the projection fails BY NAME. The absence assertion also fails if the field name appears anywhere in the serialized document, so a partial redaction cannot pass.",
        "commands_and_output": [
          "pnpm --filter @objectstack/rest exec vitest run meta-object-fls  ->  Test Files 1 passed (1) | Tests 88 passed (88)",
          "pnpm --filter @objectstack/runtime exec vitest run meta-object-fls  ->  Test Files 1 passed (1) | Tests 67 passed (67)",
          "pnpm --filter @objectstack/metadata-core exec vitest run object-schema-fls  ->  Test Files 1 passed (1) | Tests 21 passed (21)",
          "pnpm --filter @objectstack/plugin-security exec vitest run get-metadata-readable-fields  ->  Test Files 1 passed (1) | Tests 7 passed (7)",
          "pnpm exec turbo run test --concurrency=4  ->  Tasks: 135 successful, 135 total | Time: 21m21.76s",
          "post-merge touched packages: metadata-core 124 passed (9 files) | plugin-security 780 passed (37 files) | rest 1027 passed (67 files) | runtime 1678 passed (112 files)",
          "pnpm exec turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*'  ->  Tasks: 120 successful, 120 total",
          "pnpm lint  ->  clean",
          "pnpm --filter @objectstack/spec check:generated  ->  All 10 generated artifacts are up to date."
        ],
        "note_on_a_misleading_first_run": "The first `pnpm test` (full turbo concurrency) reported four packages failing in 21 seconds. Every one of them passed in isolation; re-running with --concurrency=4 gave 135/135 green in 21 minutes. It was machine resource contention, not a real failure — recorded here because the raw log looks like four red packages.",
        "gates": "Enumerated from .github/workflows/lint.yml and run one by one, not from memory: the ESLint job's 22 check:* steps (slot-lookup, query-options-erasure, verify-stand-in, nul-bytes, doc-authoring, docs-audit-scope, role-word, quick-reference-counts, adr-anchors, org-identifier, authz-resolver, service-providers, route-envelope, error-code-casing, wildcard-fallthrough, meta-type-normalized, init-service-contract, durability-log-level, startup-registry-verdict, objectui-changeset, release-notes, release-body, node-version, workflow-status-functions, shard-attestation, published-files, engine-double-contract, resume-authority-declared, merge-driver, spec-parsed-alias) and the TypeScript Type Check job's full sequence (type-check-coverage, driver-conformance, stall-guard, spec tsc --noEmit, spec's twelve check:* gates, skill-frame-sync, skill-compatibility, build, full typecheck, type-check-debt, examples typecheck, downstream-contract typecheck, doc-formula-expressions, i18n, i18n-coverage). ALL GREEN. check:nul-bytes caught one real slip on the first pass — the fingerprint separator had been written as a raw NUL byte rather than the escape text; fixed in its own commit (a5839f2).",
        "reverse_verification": {
          "method": "Disabled the projection (applyObjectSchemaMask returns its input for every posture) and re-ran both dispatch suites. Directions predicted in writing BEFORE running.",
          "predicted": "rest: 28 contract failures (the 4 projection-dependent rows x 7 exit groups) + 2 ETag failures + 1 empty-projection failure = 31. runtime: 4 projection-dependent rows x 6 resolvers = 24. Staying GREEN: both D4 exemption rows, all three D6 posture rows, the unrestricted-caller row, the D8 opt-out row.",
          "measured": "rest: 31 failed / 57 passed. runtime: 24 failed / 43 passed. The named guard rails all stayed green.",
          "verdict": "Exact match on both counts and on the direction of every guard rail. Projection restored, re-run: 88/67 green. No measurement contradicted a prediction."
        }
      },
      "mandatory_questions": {
        "q1_effect_on_6190_and_5079": "Both UNAFFECTED — measured, not assumed. #6190 (org flow overlay cold-boot unbind, narrowed) is about WHICH overlay row binds at boot; #5079 (a deleted overlay still listed in /meta) is about WHETHER an item appears in the listing at all. This change touches neither: it operates strictly on the `fields` record of a document an exit has ALREADY decided to serve, and it is a no-op for any document without a `fields` record. It neither adds nor removes list entries (the list exit projects each item in place and never filters), and it never changes which resolver answers. Same reasoning for #6562 (overlay-backed vs registry-backed reads answering different field sets): the mask layers on top of whatever the exit serves, so #6562's divergence survives underneath it unchanged — I did not try to fix it here. The one place they touch is a note in the runtime suite: masking now runs identically on both of #6562's paths, so whoever fixes it gets the projection for free on the converged path.",
        "q2_d5_sweep": "Yes — three uncovered schema-bearing outlets found, all searched-first and filed unassigned with the `finding` label: #6603 (the masked-read -> PUT round-trip, see open_questions), #6599 (/meta/_drafts and /metadata/_drafts serve draft object schemas unmasked), #6601 (a section-less public form publishes every non-server-managed field of its target object to ANONYMOUS callers, because `allowed.size === 0` falls through to all fields). Checked and confirmed NOT outlets: /openapi.json (its own comment says 'object, shape not described here' — it never enumerates fields), OData $metadata / describe-style surfaces (no such route exists in this repo; odata.zod.ts is a query-adapter spec only), the client SDK describe calls (they proxy REST and inherit the mask), and /data/:object/export (already projects columns via #3547). Three outlets the ADR's D5 enumeration did NOT name were found and are covered in this PR rather than filed: the ?layers=true diagnostic view (three full schemas behind a query flag), the compound-name read, and the runtime's legacy one-segment /metadata/:objectName read."
      },
      "open_questions": [
        {
          "id": "put-round-trip",
          "severity": "high — read before merging",
          "question": "ADR-0106 is SILENT on the masked-read -> PUT round-trip, and the interaction is real: PUT /meta/:type/:name carries NO capability gate (only enforceAuth — the neighbouring _migrate route's own comment says so in as many words), so a restricted caller who GETs a masked schema, edits a label, and PUTs it back DELETES the fields that were masked out of their read. Before this change they round-tripped the full schema, so the masking is what creates the hazard.",
          "action_taken": "Per the ruling, I did NOT invent an answer: the write path is untouched. Filed as #6603 with the four dispositions spelled out (merge-don't-replace / refuse the write / detect-and-409 / accept the loss).",
          "recommendation": {
            "correctness": "Refuse the write — require `manage_metadata` on PUT /meta/:type/:name, as the _migrate route already does. Then 'the caller who can write an object schema is the caller who sees all of it' becomes an invariant rather than a coincidence, and the round-trip hazard disappears rather than being detected.",
            "cost": "Smallest of the four (one gate, one test). Merge-don't-replace is the largest and changes PUT semantics for every other caller and every other type.",
            "risk": "It is an access change beyond ADR-0106's scope, so it needs the maintainer's call or an ADR-0106 addendum — which is exactly why it is not in this PR."
          }
        },
        {
          "id": "d8-spec-seat",
          "severity": "medium",
          "question": "D8's config key `metadata.maskObjectFields` needs a declared seat on `MetadataEndpointsConfigSchema` in packages/spec — which was off this task's file surface.",
          "action_taken": "Checked first whether the config is accepted without a spec edit, as instructed: it is. `normalizeConfig` reads the raw RestServerConfig object and already reads two undeclared keys the same way (`(api as any).enableOpenApi`, `(api as any).enableSearch`), so `(metadata as any).maskObjectFields` works at runtime today. I shipped the escape hatch through that precedent rather than shipping enforcement with no way out, and added `OS_ALLOW_UNMASKED_OBJECT_METADATA` (Prime Directive #9's OS_ALLOW_{X} shape) as the deployment-wide knob — which the runtime /metadata dispatcher needs anyway, having no REST config to read.",
          "recommendation": "A follow-up spec PR should declare `maskObjectFields: z.boolean().default(true)` on MetadataEndpointsConfigSchema so the key is type-safe in objectstack.config.ts. Honoured-but-undeclared is the debt AGENTS.md names; it is deliberate here only because the surface forbade the fix. I did not file this as an issue since it is squarely this PR's follow-through — say the word and I will."
        },
        {
          "id": "isecurityservice-seat",
          "severity": "medium",
          "question": "D7's `getMetadataReadableFields` is registered as an explicit `Object.assign` extension of the typed `ISecurityService` literal rather than declared on the interface, because `packages/spec/src/contracts/security-service.ts` was off surface.",
          "action_taken": "Registered as a visible extension (not a silent cast at the call site) with a comment naming why; the dispatch layer feature-detects it and falls back to `getReadableFields`, which is the contract's own documented degradation rule.",
          "recommendation": "Declare it on ISecurityService in the same follow-up spec PR as the D8 key — one spec change closes both."
        },
        {
          "id": "empty-readable-set",
          "severity": "low — flagged as an interpretation",
          "question": "What should an exit answer when `getReadableFields` legitimately returns `[]`? D6's 'never an empty-fields 200' is written inside the THROW tier's row, so the ADR does not literally rule on this case.",
          "action_taken": "Treated as the error tier (503). `[]` is only ever produced where plugin-security's own posture read failed closed (#3545), so it is a degraded answer wearing a valid shape — and an empty-fields 200 is exactly what D6 calls 'the worst option: silently wrong UI AND cacheable poison'. Pinned in both dispatch suites.",
          "recommendation": "Keep as implemented. If a reviewer prefers `fields: {}` for a genuinely all-denied permission set, it is a one-line change in `applyObjectSchemaMask`'s `emptied` flag and one case-table row."
        }
      ],
      "out_of_scope_findings": [
        "#6603 — masked read -> PUT round-trip deletes the masked-out fields (filed, unassigned, `finding`)",
        "#6599 — /meta/_drafts and /metadata/_drafts serve draft object schemas unmasked (filed, unassigned, `finding`)",
        "#6601 — a section-less public form publishes every non-server-managed field of its object to anonymous callers (filed, unassigned, `finding`)"
      ],
      "pm_assumptions_checked": {
        "same_day_churn": "CONFIRMED and it changed the work. #5895's envelope convergence means `translateMetaEnvelope` is the single rebuild point, so the issue's 'cached 与非 cached 两条路径' wording is stale as a description of the code — the two branches still differ in HOW they reach a document, and both are masked, but the sweep found three more outlets on the same route that neither the issue nor ADR-0106 D5 names (?layers=true, the compound-name read, and the runtime's legacy one-segment read). All three are covered.",
        "pr_6561": "CONFIRMED on both counts. The byte-identical baseline is measured against post-#6561 main (governServedItem -> applyAuditFieldGovernance runs per exit in metadata-protocol, underneath the mask). Its shared-normalizer pattern is the shape I followed: one pure function in @objectstack/metadata-core plus one case table driving every exit, exactly as protocol.audit-field-governance.test.ts does.",
        "file_surface": "Held, with one deliberate extension I am flagging rather than burying: the shared normalizer and its case table are NEW files in `packages/metadata-core` (plus one dependency line in packages/rest/package.json), which the surface did not list. The alternative was five copies of the projection across two packages — the exact drift #6561, #5619 and #4513 were each written to prevent, and the thing that makes ADR-0106 D5 false in practice. metadata-core depends only on { spec, zod }, so there is no new edge and no cycle. Nothing else moved: no packages/spec, no objectui, no content/docs/releases, no metadata-protocol signature.",
        "no_new_fake_engines": "None of the four new suites introduces a fake engine — the REST suites use protocol doubles and the runtime suites use service doubles, so `assertEngineDeleteDispatch` has no call site here. `pnpm check:engine-double-contract` is green."
      }
    }

    Generated by Claude Code

  6. baozhoutao commented on Aug 8, 2026

    @baozhoutao
    Contributor

    ACCEPT (PM review, domain:metadata seat, session session_01KDU3qAuJyajAQm3GkUXdfA) — PR #6612, now ready + queued.

    Review verified against GitHub: 18 files; every declared surface present (rest meta exits, runtime object branch, plugin-security D7, docs CDN note, ADR status flip + anchors, minor changeset × 4) and the one flagged extension — the shared projection + case table in @objectstack/metadata-core with a single new rest → metadata-core dependency edge — accepted: it follows the #6561/#5619 shared-normalizer criterion exactly, and five copies across two packages is the drift D5 exists to prevent. packages/spec, objectui, metadata-protocol signatures, releases/ all untouched as required. ESLint and TypeScript Type Check completed: success, 26 checks zero failures (incl. Validate Package Dependencies for the new edge). Reverse verification matched its written predictions exactly (rest 31 red / runtime 24 red with guard rails green). D5 sweep delivered: three unnamed outlets covered in-PR (?layers=true, compound-name, legacy one-segment) and three findings filed (#6599, #6601, #6603).

    Sequencing judgment on #6603 (masked GET → PUT deletes masked fields), stated for the veto window rather than asked as permission: this PR lands ahead of #6603's resolution. Rationale: PUT /meta/:type/:name is enforceAuth-only today, so any authenticated caller could already clobber schemas arbitrarily — the mask converts a malicious capability into an innocent hazard, it does not create write access; the realistic schema editors (admin / isSystem) are D4-exempt, so their round-trips read unmasked and stay lossless; and the shipped defect this PR fixes is a ruled v17 information-disclosure gap. #6603 is being escalated to the decision inbox now with the dev's recommendation (require manage_metadata on the PUT, closing both the pre-existing clobber hole and the round-trip hazard in one gate). The maintainer can stop this landing by closing the queue entry.

    Also recorded: the D8 config key and the ISecurityService.getMetadataReadableFields member are honoured-but-undeclared (precedented, deliberate — spec was off-surface); the follow-up spec transfer card is being filed by this seat per rule 3. The issue closes on merge via the Fixes line; #3661 ③ is thereby delivered.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions