Repository navigation
QA run · access-security (FULL area) · 92f26f75 · 2026-08-11 · 8 PASS / 2 PARTIAL / 8 FAIL / 1 BLOCKED #7637
Description
Full access-security area run of the checklist-test skill — all 19 items (18 runnable + 1 fixture-blocked) driven against a live showcase across 4 opus subagents, each on its own port + file DB. Text-only per RUNNER.md.
Result: 8 PASS · 2 PARTIAL · 8 FAIL · 1 BLOCKED.
⚠️ One security-sensitive finding is NOT described here.rls-both-sidesclause 5 andowd-sharing-matrixclause 4 are the same defect, and its write-up was delivered privately asFOLLOW-UPS.mdD11 per the maintainer ruling recorded on #7463 (same handling as D1). Everything below is the non-sensitive remainder.
Environment — framework 92f26f75 (branch claude/platform-test-checklist-ocwugl) · console bundle 09987b680 (packages/console/dist/.objectui-sha) · showcase app · isolated port + file DB per batch · personas provisioned as real runtime sign-ups, never fixtures.
✅ PASS — 8 items
The enforcement core is genuinely strong. Highlights, all proven with effect-follow-up reads rather than status codes alone:
crud-permission-matrixdrove 124 cells (31 access-matrix rows × 4 verbs), each as a dedicated persona holding exactly that set: zero withheld cells answered 2xx, zero denied edits mutated, zero denied deletes removed. (The item still fails on one allowed cell — see below.)write-path-guards(7/7) — readonly fields are admit-and-strip withdroppedFields+ thex-objectstack-dropped-fieldsheader on both create and update;owner_idforging refused on create, update and disown, with an admin-context marker query proving 0 rows were planted; the guard gates on identity, not on the key's presence (own-id create is allowed);readonly_whenlocks fire per-field including the bulk door (安全:readonlyWhen 服务端剥离仅覆盖单条 update 路径,多行 updateMany 不强制(条件只读可被批量绕过) #3042); mixed-batch validation is per-row (Validation rules, requiredWhen and option visibleWhen are silently skipped on multi-row updates (options.multi) #3106 does not reproduce).anonymous-deny-surfaces(6/6) — all six surfaces 401 with the platform message, and the gate fires before object/record resolution (a nonexistent id still 401s, never 404); every body matched exactly one envelope family with no hybrid dialect (ANONYMOUS_DENY_BODY自称是「每个 seam 都返回的唯一 401 body 形状」,但 dispatcher 侧五个 seam 返回的是另一种 wrapper #5632); declared-public forms still serve anonymously, so the deny doesn't blanket the server. AnonymousDELETE /automation/<flow>→ 401 with the flow still registered ([17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #5519 does not reproduce).scope-depth-asymmetry(6/6) — read-org/write-own asymmetry proven on the same record id across four personas: manager reads a foreign private-OWD row 200 but PATCH → 403 with the row verifiably unchanged, while an org-writeScope persona PATCHes that same row 200 and it persists — so the denial is the guard, not a dead route.sharing-rules-widen(5/5) —/security/explainshows the decision layer-by-layer (owd_baseline narrows, sharing widens naming the rule, and for an outsider "1 share(s) attached; none grants the caller access"). Audit sibling declared-metadata↔record two-store types (sys_position, sys_sharing_rule, sys_capability) per ADR-0094 addendum #2909 regression checked: three shares survived a real restart byte-identical.fls-mask-and-strip(5/5) — a mixed forged write is refused atomically (the allowed field doesn't land either); the filter oracle is refused (filtering by a hidden field → 403, while the identical filter as admin returns rows).⚠️ Authoring caveat: maskingbudget+spentleaves the formula fieldbudget_remainingunmasked — a formula over masked inputs is not auto-masked.record-access-explain(5/5) — explain agrees with reality in both directions across three personas on one record.⚠️ Reader trap: the panel's top banner is the object-level verdict and reads ALLOWED even for a persona whose record verdict is not visible.public-form-intake(7/7 + 3 negatives) — forgedowner_id/organization_id/created_byall land null; a__proto__body smugglingowner_id/statusinherited nothing; no whitelisted field opens an anonymous lookup, so the form is not a directory oracle; 11 anonymous probes confirm the intake door opens nothing else.
🔴 FAIL — 8 items
1. crud-permission-matrix — an allowed create returns 500 while the row is written
123 of 124 cells are correct. The one break is a permitted operation, not a denied one.
Reproduction rule — boot showcase, sign up a plain member (no explicit grants), admin creates a project, then as the member:
POST /api/v1/data/showcase_task {title, project:<id>, status:'todo'}
Expected 201 (the access matrix says create:true; /security/explain confirms allowed=true). Actual 500 INTERNAL_ERROR — yet an admin read shows the task did persist, with owner_id = the member. Reproduced 4×.
Server log: WARN Roll-up summary recompute failed {childObject:showcase_task, parentObject:showcase_project, field:task_count, error: Access denied: operation update on showcase_project …} ×2, then ERROR SummaryRecomputeError '…the triggering records WERE written (summary values may be stale)'.
Root cause (located). objectql/src/engine.ts:6139 — recomputeSummaries issues the parent roll-up write under the caller's execution context, so it passes through security as the caller. showcase_project declares two Field.summary roll-ups over showcase_task, and member_default grants read but not edit on showcase_project, so the internal recompute is refused; withTransientRetry then retries a non-transient PERMISSION_DENIED, and engine.ts:8454 throws SummaryRecomputeError, which REST maps to 500.
Contrast isolating it: the identical POST succeeds 201 for showcase_contributor (holds allowEdit on showcase_project) and for admin.
Suggested fix: run the roll-up recompute in a system context — it is an engine-internal derived write, not a caller write. At minimum, stop retrying a permission denial and stop surfacing a committed write as a 500. A tracker search suggests this is unreported.
2. owd-save-gate — the ADR-0090 D11 authoring gate never runs on any host-config deployment
Both layers fail: nothing on this deploy refuses a D11 violation. Studio turns the external-OWD description amber and literally says "publishing will be rejected (ADR-0090 D11)" — then leaves Save draft and Publish enabled, and both succeed.
Reproduction rule — PUT /api/v1/meta/object/qa_probe {…, sharingModel:'private', externalSharingModel:'public_read'}. Expected 403 owd_external_wider. Actual 200, and GET returns the violating pair persisted — on the draft path, the active path, and with ?package=. Reproduced 3× on one boot, 3× on a second boot with a fresh DB, plus once through the browser.
Root cause (located). metadata-protocol/src/protocol.ts:9667 wraps the #3050 authoring-gate call in if (this.environmentId !== undefined). The showcase config is a host config, so isHostConfig → shouldBootWithLibrary(config) === false, and serve.ts takes the lightweight assembler which constructs new ObjectQLPlugin() with no options → environmentId stays undefined → the guard is false and runAuthoringGate is never called. So R2 (owd_external_wider) and R1 (owd_widening_forbidden) execute on no self-hosted/host-config deployment.
This is the exact proxy-signal hazard #6710 already diagnosed and retired for the sibling #4463 gate. protocol.ts's own comment names "the CLI's lightweight host-config assembler … ALSO leaves environmentId undefined, and it serves an end-user PUT /api/v1/meta/"* — and then keeps the #3050 gate on the retired signal anyway. Suggested fix: key it on the declared authoringChannel, as #6710 did.
The gate logic is not the bug — object-posture-gate.test.ts is 18/18 green. A grep for owd_external_wider finds only the gate source and its unit test: there is no integration test exercising R2 through the real save path.
Security impact is bounded today: external-principal enforcement is #2696-planned, so a wider external baseline does not itself disclose anything yet. That is why this one is filed publicly rather than held back — it is an authoring-validation gap, not a live disclosure.
3. audit-log-browser — four of the ten audit actions have no writer anywhere
Reproduction rule — on a fresh boot: (1) sign up + sign in a member, then GET /api/v1/data/sys_audit_log?$filter={"action":"login"} as admin → total 0; the only trace is an unattributed update sys_user row (user_id null) diffing last_login_at. (2) PUT /api/settings/branding {"workspace_name":"X"} → 200, then filter {"action":"config_change"} → total 0; the event went to sys_setting_audit with action set instead. Both reproduced twice.
Action census over all 57 rows: {'create':48,'update':7,'delete':2} — only CRUD actions ever materialize. The delete half is correct (full old_value, actor, tenant).
Root cause (located). plugin-audit/src/audit-writers.ts subscribes only to the ObjectQL wildcard before*/after* CRUD lifecycle events, so it can emit create/update/delete/restore and nothing else. A repo-wide search finds no writer for login, logout, permission_change, config_change, export or import — though all are declared in the action enum of sys-audit-log.object.ts. Settings writes are audited by settings-service-plugin.ts:315 into sys_setting_audit, never sys_audit_log, while settings-service.types.ts:16 documents the service as "Emit sys_audit_log rows for every successful write."
Net effect: four enum values, two shipped list views (auth_events, config_changes) and two dashboard widgets (system_overview.dashboard.ts L89 + L109) are permanently empty. The console makes it worse by listing login/config_change in its own ACTION_OPTIONS — the client offers filters for rows the server never writes.
The tamper-proofing half is solid: POST/PATCH/DELETE to sys_audit_log all 405 OBJECT_API_METHOD_NOT_ALLOWED, list total unchanged.
4. permission-matrix-edit-loop — the field-level half of the permission matrix is dead for every object
The object-verb loop is fully working (checkbox → PUT → 200 → the persona's live DELETE flips 403→200, and back again on reverse; the editor re-reads the saved state after reload).
Reproduction rule — open /_console/apps/<app>/metadata/permission/<set> and expand any object row. Expected the field sub-table listing that object's fields with a readable/editable checkbox pair each. Actual "No fields registered for this object." and zero field checkboxes — for every object. Reproduced twice on fresh loads for two objects. Not a data problem: the network shows GET /api/v1/meta/object/showcase_project → 200 carrying 21 fields.
Root cause (located). objectui PermissionMatrixEditor.tsx ensureFields() does const obj = await client.get('object', objectName); const raw = obj?.fields; — but MetadataClient.get() (metadata-client.ts:495-512) returns the raw server envelope with no unwrapping, and the framework answers {type, name, item:{…fields}}. So obj.fields is always undefined and every object reports zero fields. The client's own docblock at metadata-client.ts:522 asserts the opposite ("the legacy get() returns the unwrapped body") — that is where the two sides disagree.
Not a stale bundle — the same code is on objectui origin/main (6d01319dd). Collateral: the RLS CEL editor's field lint/autocomplete (loadObjectFields) is empty for the same reason.
5. record-share-grant-revoke — package-seeded sharing rules are unaddressable by name
Clauses 0–5 all pass, including that read access confers no re-share authority (403, ADR-0111 D1) and that a mis-scoped revoke 404s with the share surviving.
Reproduction rule — stock boot, admin bearer: (1) GET /api/v1/sharing/rules → 200 {"data":[]} though sys_sharing_rule lists 4 active seeded rules. (2) GET /api/v1/sharing/rules/share_red_projects_with_execs → 404 RULE_NOT_FOUND. (3) POST …/<name>/evaluate → 404. (4) Control: POST /api/v1/sharing/rules/srule_<row id>/evaluate → 200 {matchedRecords:1, expandedUsers:2, grantsCreated:1}.
Root cause. plugin-sharing/src/sharing-rule-service.ts applies a strict-equality org filter that package-seeded rows cannot satisfy: listRules does if (orgId) where.organization_id = orgId, and getRule's name fallback is orgId ? {name, organization_id: orgId} : {name} — while package-seeded rows carry organization_id = null and an authenticated admin's context carries organizationId='org_…'. Only the unfiltered by-id branch survives.
Confirming experiment: a rule created via the API (where defineRule stamps organization_id from the same context) is findable by name, listed, and evaluable by name.
Blast radius: every package-declared sharing rule is invisible and unmanageable through the admin API. Enforcement is unaffected, because the boot reconcile uses SYSTEM_CTX which carries no org — which is exactly why this stayed invisible. A sibling batch independently bounded the bug to package-seeded rows and confirmed the console's Sharing Rules page is not affected (it reads the data API, not /api/v1/sharing/rules, so it lists all four honestly).
6. suggested-binding-loop — two defects
(a) The isDefault suggestion is never surfaced on stock. GET /api/v1/security/suggested-bindings → 200 {suggestions:[], synced:{created:0}} and the table is empty, though the permission set and its everyone binding both exist. Discriminating step: delete that binding row and re-list → synced {created:1} and a PENDING row appears — so the declaration is collected. Root cause: suggested-audience-bindings.ts syncAudienceBindingSuggestions does if (bound) continue when no row exists yet, and the "confirmed (observed)" transition only fires for a row already in status pending — but the security plugin auto-binds the app's isDefault set to everyone at boot, before any list call, so stock always takes the continue. This contradicts the module's own docblock.
(b) An unknown ?status filter returns 200 EMPTY instead of 400 — exactly the "reads as there are no suggestions" failure the clause forbids. GET …/suggested-bindings?status=garbage → 200 empty (same for ?status=PENDING). Root cause: the live route is rest-server.ts registerSecurityEndpoints, which forwards req.query.status through with no validation; the isSuggestionStatus 400 guard exists only on the parallel runtime dispatcher domain, whose own comment describes precisely this observed behaviour. The fix landed on the dispatcher path only.
Clauses 2–6 pass, including the one the item carried as a knownGap — the runner closed it by provisioning rather than recording blocked, deleting the stock auto-binding to raise a genuine PENDING row and installing a scratch package to raise a second.
🟡 PARTIAL — 2 items
capability-declaration-lifecycle
Declaration→sys_capability seeding is exact, including both documented fallbacks (humanize(name) label, generated description). Platform-capability hijack is refused: a package declaring manage_users gets a located WARN, skippedPlatform:1, and the platform row is unchanged — though note the refusal is a WARN and boot proceeds; the protection is that the row isn't overwritten, not that the declaration is rejected at authoring. Malformed declarations abort boot at Zod parse with per-path messages, before any sys_capability write — so #5961 (an unvalidated row reaching the authz namespace) does not hold.
Clause 1 is partial, and the item's knownGap is confirmed wider than stated. The three-way grant/deny/regrant resolves by name — but only demonstrable with a platform capability (manage_metadata), because no shipped resource enforces a package capability server-side. The three showcase actions carrying requiredPermissions have no REST execution route on this build (/api/v1/actions/<n>, /api/v1/action/<n>, /api/v1/objects/<o>/actions/<n>, /api/v1/data/<o>/actions/<n> all 404; rest-route-ledger.ts registers no action-invoke route), so their requiredPermissions gate is client-side only. The grant side was provisioned for real; there is simply no server surface on which to exercise the contrast.
readonly-package-locks-studio
Studio's read-only lock is real, not cosmetic: 207/207 checkboxes disabled, 92/92 bulk buttons disabled, no Save rendered, Publish disabled; a forced click left data-state unchanged. The direct write is refused and the artifact is byte-identical afterwards (sha256 match before/after the denied sequence).
Clause 1 is partial: the refusal is a ledgered code, but not one of the two the clause names and it does not key on package writability — PUT /api/v1/meta/object/showcase_task → 403 NOT_OVERRIDABLE ("'object' is not allowOrgOverride in the registry"), the same with ?package= pointing at either a read-only or a writable package. ITEM_LOCKED and WRITABLE_PACKAGE_REQUIRED are both in the error-code ledger and neither was ever emitted on this path. SysMetadataRepository.assertAllowed discriminates on the metadata type's overlay policy, not on a package-writability check.
OS_METADATA_WRITABLE=permission, PUT /api/v1/meta/permission/showcase_contributor?package=com.example.showcase — a set belonging to the read-only package — succeeds (200, env-wide overlay), while Studio still renders that matrix fully disabled with a "Read-only" badge. The hatch is documented as unlocking the write, so this may be intended; but the Studio badge then asserts a lock the server is not applying. (The overlay was reverted afterwards.)
⛔ BLOCKED — 1 item
share-link-capability-tokens — fixture-blocked on stock showcase, unchanged from its ledgered blocked:{by,ref}.
Cross-cutting findings
verify --rlsreports 0 HOLES but structurally cannot reach the by-id-write class. Its member probe holds no object grants, so every such probe is masked by the object-level gate (403) before record scope is ever tested. A second probe persona holding object read+edit but outside the record scope would catch it. Compounding this, ashowcase_accountauto-record 400 cascades into 5 downstream skips, so 8 of 23 objects are skipped on a stock run — and a skip is exactly where the privately-reported D11 defect hid. This is the single highest-value fix in this report: the tool's green is currently not evidence.authz-conformance.matrix.tsmarks bothrls-by-id-write(fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994) andcontrolled-by-parentasstate:'enforced'; neither holds as shipped. The showcase's ownpermission-sets.tscomment makes the same false claim.- A third dispatcher-vs-REST divergence, and the first in the opposite direction. Earlier instances were routes implemented on the dispatcher but never mounted on REST (
/meta/types,/meta/:type/:name/published,/meta/objects/:name/state/:field). Here a fix (the?status400 guard) landed on the dispatcher while the Hono/REST route the server actually serves never got it. This strengthens the case for the route-ledger↔live-mount parity gate (Three ledgered /meta routes are never mounted and die in the/meta/:typecatch-all — the route audit can't see this class because it treats the ledger as ground truth for what's mounted #7526) — and argues it should cover behaviour divergence, not just mount presence. - Console permission-matrix editor is read-only by default. On a stock boot it renders "Read-only (OS_METADATA_WRITABLE not enabled)" with every checkbox disabled, at both the metadata-admin route and inside Studio's Access pillar for a writable package — because the editor computes
writable = !!resolved.allowOrgOverride && !readOnlyand the server returnsallowOrgOverride:falsefor typepermission. Yet the server accepts the package-door write in that same default env (PUT …?package=<writable pkg>→ 200 viaallowRuntimeCreate). The type gate locks a surface the server would allow.
Checklist maintenance falling out of this run
owd-sharing-matrixclause 4 andrls-both-sidesclause 5 are the same defect — dedupe so it isn't counted twice.suggested-binding-loop'sknownGapstext is wrong on two points: stock produces no row at all (not a "confirmed (observed)" row), so the dependent clauses have nothing to run against until the unbind sequence is performed.record-share-grant-revokeclause 6 says the materialized share carriessource_id= the rule name; the implementation writes the rule row id (the stable FKpurgeRuleGrantsrelies on). Semantic intent holds, text is wrong.sharing-rule-authoring-uinamesshowcase_projectas "a private-OWD object", but it reportssharingModel:'public_read_write', so a rule on it widens nothing observable. The private-OWD showcase objects areshowcase_contact,showcase_inquiry,showcase_private_note— the runner substitutedshowcase_contact.- Runner-environment fact worth adding to the briefing:
viewis in the overlay-allowed set (view, dashboard, report, translation, email_template), soPUT /api/v1/meta/view/<name>works on the stock read-only showcase package with no escape hatch.
One false alarm was raised and retracted by the runner before reporting: an apparent allowDelete:false bypass turned out to be documented modifyAllRecords ("Modify All Data") semantics per ADR-0066 D2, isolated with a 4-way A/B and not filed.
Activity
huangyiirene commentedon Aug 11, 2026
Extraction ledger — COMPLETE. Closing.
All 8 public FAILs, both PARTIALs, the cross-cutting findings and the checklist-maintenance list extracted; every framework defect re-verified live on
origin/main(none already-fixed), zero duplicates.crud-permission-matrixrecomputeSummariesissues the parent roll-up under the caller's context, then retries a non-transientPERMISSION_DENIEDdomain:engine-coreowd-save-gateenvironmentIdinstead ofauthoringChannel, the exact proxy-signal hazard #6710 retired for the sibling gatedomain:metadataaudit-log-browserdomain:identitypermission-matrix-edit-loopMetadataClient.get()returns the raw envelope, soobj.fieldsis always undefinedrecord-share-grant-revokeorganization_id = nullrows cannot satisfydomain:identitysuggested-binding-loop(a)isDefaultsuggestion is never surfaced on stock — the plugin auto-binds at boot before any list call, so sync always takesif (bound) continuedomain:identitysuggested-binding-loop(b)?statusreturns 200 EMPTY instead of 400 — the guard exists only on the dispatcher; the live REST route forwards it unvalidateddomain:clicapability-declaration-lifecycle(PARTIAL)requiredPermissionson actions is client-side onlydomain:clireadonly-package-locks-studio(PARTIAL)ITEM_LOCKED/WRITABLE_PACKAGE_REQUIREDare ledgered but never emitteddomain:metadataverify --rls's green is not evidence;authz-conformance.matrix.tsoverclaimsenforced; the console permission editor is read-only by default on a surface the server would allowdomain:clidomain:devxThe 8 PASS items produced no cards. The enforcement core measured genuinely strong — 124 access-matrix cells with zero withheld cell answering 2xx.
The private finding
rls-both-sidesc5 andowd-sharing-matrixc4 are the same defect, delivered privately asFOLLOW-UPS.mdD11. Under the maintainer's 2026-08-11 instruction (verbatim: 「立一张私有修复卡」) its fix card is filed as #7665 (bug+security+pm:queue+domain:identity+target:v17) — a by-id write not gated by record visibility, reproduced on three objects. Nothing about it is described in this public record or in the extracted cards above.owd-save-gatecard (#7674) concerns "ADR-0090 D11" — an ADR clause number for the external-OWD authoring gate. That is unrelated to the private FOLLOW-UPS D11. Two different things, same two characters.Two things the extraction added beyond the run
#7685 is the highest-leverage card in this batch, and it is a tooling card.
verify --rlsreports 0 HOLES while structurally unable to reach the by-id-write class — its probe holds no object grants, so every probe is masked by the object gate before record scope is tested, and 8 of 23 objects are skipped on a stock run. The private D11 defect hid in exactly that blind spot. Until this is fixed the tool's green is not evidence, which is why it is filed alongside the enforcement fix rather than behind it.#7678 is a dispatcher-vs-REST divergence in the opposite direction from the earlier ones. The
/metacases were routes implemented on the dispatcher but never mounted on REST; here a fix landed on the dispatcher while the Hono route the server actually serves never got it. Cross-linked to #7526 with the argument that the route-ledger↔live-mount parity gate should cover behaviour divergence, not just mount presence.Provenance: maintainer standing rule that every QA problem becomes an issue; run records close at extraction, same exit as #7514/#7463/#7627.
Generated by Claude Code
docs(qa): dedupe the private D11 defect and correct four stale sharin…
fix(plugin-security): require by-id write targets to be within the ca…
docs(qa,spec): checklist maintenance from four QA runs — identity-aut…
docs(rest,qa): record the deliberate absence of a REST action-invoke …