Skip to content

QA run · access-security (FULL area) · 92f26f75 · 2026-08-11 · 8 PASS / 2 PARTIAL / 8 FAIL / 1 BLOCKED #7637

Closed

Description

@baozhoutao

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-sides clause 5 and owd-sharing-matrix clause 4 are the same defect, and its write-up was delivered privately as FOLLOW-UPS.md D11 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:

🔴 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.

⚠️ Caveat worth a decision: with the documented operator hatch 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

  1. verify --rls reports 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, a showcase_account auto-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.
  2. authz-conformance.matrix.ts marks both rls-by-id-write (fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994) and controlled-by-parent as state:'enforced'; neither holds as shipped. The showcase's own permission-sets.ts comment makes the same false claim.
  3. 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 ?status 400 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/:type catch-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.
  4. 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 && !readOnly and the server returns allowOrgOverride:false for type permission. Yet the server accepts the package-door write in that same default env (PUT …?package=<writable pkg> → 200 via allowRuntimeCreate). The type gate locks a surface the server would allow.

Checklist maintenance falling out of this run

  • owd-sharing-matrix clause 4 and rls-both-sides clause 5 are the same defect — dedupe so it isn't counted twice.
  • suggested-binding-loop's knownGaps text 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-revoke clause 6 says the materialized share carries source_id = the rule name; the implementation writes the rule row id (the stable FK purgeRuleGrants relies on). Semantic intent holds, text is wrong.
  • sharing-rule-authoring-ui names showcase_project as "a private-OWD object", but it reports sharingModel:'public_read_write', so a rule on it widens nothing observable. The private-OWD showcase objects are showcase_contact, showcase_inquiry, showcase_private_note — the runner substituted showcase_contact.
  • Runner-environment fact worth adding to the briefing: view is in the overlay-allowed set (view, dashboard, report, translation, email_template), so PUT /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

  1. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    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.

    Run item Finding Card Lane
    crud-permission-matrix an allowed create returns 500 while the row is written — recomputeSummaries issues the parent roll-up under the caller's context, then retries a non-transient PERMISSION_DENIED #7673 domain:engine-core
    owd-save-gate the ADR-0090 D11 authoring gate never runs on any host-config deployment — keyed on environmentId instead of authoringChannel, the exact proxy-signal hazard #6710 retired for the sibling gate #7674 domain:metadata
    audit-log-browser 4 of 10 declared audit actions have no writer anywhere — 2 shipped list views and 2 dashboard widgets are permanently empty, and the console offers filters for rows the server never writes #7675 domain:identity
    permission-matrix-edit-loop the field-level half of the permission matrix is dead for every object — MetadataClient.get() returns the raw envelope, so obj.fields is always undefined objectstack-ai/objectui#4271 objectui
    record-share-grant-revoke package-seeded sharing rules are unaddressable by name — a strict org filter that organization_id = null rows cannot satisfy #7676 domain:identity
    suggested-binding-loop (a) the isDefault suggestion is never surfaced on stock — the plugin auto-binds at boot before any list call, so sync always takes if (bound) continue #7677 domain:identity
    suggested-binding-loop (b) unknown ?status returns 200 EMPTY instead of 400 — the guard exists only on the dispatcher; the live REST route forwards it unvalidated #7678 domain:cli
    capability-declaration-lifecycle (PARTIAL) no shipped resource enforces a package capability server-side — there is no REST action-invoke route at all, so requiredPermissions on actions is client-side only #7680 domain:cli
    readonly-package-locks-studio (PARTIAL) the read-only refusal keys on the metadata type's overlay policy, not package writability; ITEM_LOCKED / WRITABLE_PACKAGE_REQUIRED are ledgered but never emitted #7682 domain:metadata
    Cross-cutting i/ii/iii verify --rls's green is not evidence; authz-conformance.matrix.ts overclaims enforced; the console permission editor is read-only by default on a surface the server would allow #7685 domain:cli
    Checklist maintenance ×6 dedupe the double-counted private defect + five stale item texts #7687 domain:devx

    The 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-sides c5 and owd-sharing-matrix c4 are the same defect, delivered privately as FOLLOW-UPS.md D11. 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.

    ⚠️ Naming collision worth stating once: the public owd-save-gate card (#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 --rls reports 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 /meta cases 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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingqa-run

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions