Skip to content

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

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). Actual500 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:6139recomputeSummaries 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-transientPERMISSION_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 rulePUT /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 isHostConfigshouldBootWithLibrary(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 bugobject-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 unattributedupdate 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.tsxensureFields() 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.tssyncAudienceBindingSuggestions 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 everyoneat 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.tsregisterSecurityEndpoints, 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 writabilityPUT /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:viewis 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.

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