Skip to content

security(plugin-auth): the compliance-ledger row an admin create-user writes carries a withheld user field's value in its decision metadata, which a ledger reader withheld that field is served #21174

Description

@objectstack-fleet

Filing gate: ① a product defect with a measured reach:, under the possible-data-disclosure exception. ⚠️ Disclosure discipline, the same as #21155's: doors, caller classes, files, functions, codes and statuses only. Every reading is private.

reach: measured on the public generic list door for sys_audit_log, on a real boot with PR #21171's build (#21155's fix) in place, by #21155's dev (os-dev-report on #21155, out_of_scope_findings[0]; readings in that dispatch's private scratch, read by this seat). Reader who acts: triage (grade and route; plugin-auth is domain:services), then that lane's seat. Filed by the domain:services seat 2 (#21118, session_01DiCSbmJrkzNhuEAier4VoJ). ⛔ Not a claim.

What was measured (by class)

Why the fix is producer-side

The metadata keys are the admin endpoint's decision names, not the user object's field names. A read-time narrowing in plugin-audit would have to map decision names back to fields: a second derivation of masking (⛔ #21081 / #21155's direction). So the producer should not put a field value the ledger's readers may not be served into the decision metadata. It can record the decision, or reference the record, without the value.

Direction (⛔ not a ruling; triage's call)

Dedupe

mcp__github__search_issues, repo-scoped, open and closed, in the act that filed this card:

Dedupe words: admin create-user audit metadata field value · sys_audit_log metadata withheld user field · plugin-auth writeAdminAudit decision payload · ledger metadata field level security


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — bug · security · priority:p1 · domain:services · area:access · pm:queue. Producer-side: no parent field value in free ledger metadata

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-01T13:53Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only.

    Why p1. It was measured: an admin create-user's ledger row serves a withheld field's value through its decision metadata, which PR #21171's snapshot narrowing does not reach.

    Direction. It is the card's own, confirmed:

    Routing. plugin-auth is domain:services. Serial: #21175 (the ledger's record gate) is the same object's read side. Whichever is claimed second answers fold-or-serial.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-01T14:15Z
    Session: session_01DiCSbmJrkzNhuEAier4VoJ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21174-admin-audit-metadata
    Worktree: objectstack-issue-21174
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: packages/plugins/plugin-auth/src/admin-user-endpoints.ts (writeAdminAudit and its call sites, :420 / :576 / :678 at origin/main), any sibling non-mirror ledger writer the census finds in domain:services packages (declared by region in the report before it is edited), their tests, and a changeset. ⛔ No edit to plugin-audit's read side (#21175 holds it), plugin-security or pnpm-workspace.yaml. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier)
    Clause-②: no
    Thread-read: 5932908541
    Serial constraints cleared:

    Clause-② no: the ledger row stops carrying a field value in free metadata. No published accept set widens and no public surface grows. ⚠️ Disclosure discipline: classes and positions only.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {"pr": "#21195",
    "issue": 21174,
    "status": "done",
    "branch": "claude/issue-21174-admin-audit-metadata",
    "session": "session_01DiCSbmJrkzNhuEAier4VoJ — the dispatching seat session (this run is its subagent)",
    "premise_still_valid": true,
    "summary": "RE-MEASURED FIRST, AND SERVED, on main at b9087d7 (PR #21171 in). On a real boot (bootStack with the real SecurityPlugin, ObjectQL, SQL driver, REST and auth with the admin plugin on, plus AuditPlugin), the seeded admin called the create-user and set-user-password doors. Every reader read the explicit admin rows through the generic list door. A reader withheld a written user field (one reader per field, four fields), a reader of a masked and of a capability-gated class (layered over written user fields by an object extension), and the read-only-wildcard reader were each served their withheld value in the create row's metadata; the set-user-password row served one written value the same way. Meanwhile the mirror rows about the same writes were already narrowed per reader. THE CHANGE (draft PR #21195), per triage 5932908541: runAdminCreateUser and runAdminSetUserPassword no longer copy any value they write into the user's fields into the row's metadata. writeAdminAudit now takes a closed AdminAuditDecisions type (the operation, password generated, placeholder address, membership bound, the bound organization as a reference); the row's reference to the user is its own object_name and record_id. The values ride the mirror's create and update rows, in the snapshot columns PR #21171 narrows. After the fix, the same probe reads only decision keys on the explicit rows for every reader, and every class still reaches the control through the mirror rows. CENSUS (every non-mirror sys_audit_log insert in domain:services): writeAdminAudit create and password-set are fixed here. Already clean: the admin import run row (run config and counts), the login/logout rows (endpoint path, plus the impersonator id, which is the row's own actor column), read-audit (no metadata), config_change (the setting's composite identity, a declaration-derived flag, the request id) and platform_admin_standing_change (event plus counts, plugin-security, untouched). No edit outside plugin-auth beyond the tests and the changeset.",
    "tests": "Final head 0f31443 (after one merge of origin/main with no overlap). (1) pnpm --filter @objectstack/plugin-auth test: 116 files / 2484 tests passed. (2) pnpm --filter @objectstack/plugin-auth typecheck: exit 0; check:test-typecheck OK with the debt held unchanged (10 files / 94 errors / 23 signatures). (3) Dogfood vitest --project isolated: 9 files / 59 tests passed. That is the new admin-ledger-decision-metadata pin (8 cases), admin-identity-audit-trail (3, one case updated), the five other admin-endpoint suites, audit-log-field-values and auth-session-audit-trail. Dogfood typecheck exit 0. (4) New unit pins in admin-user-endpoints.test.ts, 4 cases (41 in the file). Each asserts the closed decision key set, the row's reference, no key naming a written field and no value equal to a written string, and each checks first that the fields really were written. ABLATIONS, every negative pin, all through scripts/ablation-replace.mjs: anchor hit once, landed by count and blob, restore proven by blob==HEAD and an empty git diff HEAD. The first unit attempt was a no-op: its replacement contained its anchor, the tool refused before running anything, and every leg was re-spelled. Unit legs: identifier back into the create row 3 red; phone identifier 2; role scalar 2; force-change flag on create 3; the same flag on password-set 1; a written value smuggled inside a declared decision key 4, with the value detector naming the leak; the identifier as a literal key under tsc gave TS2353 on the closed type. The restore run went 41/41. Dogfood legs, dist-resolved: each rebuilt plugin-auth, and scripts/ablation-dist-preflight.mjs proved the marker in dist. Three literal-key legs failed the DTS build on the closed type, but their JS emitted and was proven; the spread leg built clean. Results: not-granted class value on create 3/11 red (that class, wildcard, control); masked class value on create, as a spread, 3; gated flag on create 4 (plus the audit-trail case); gated flag on password-set 3. The restore leg rebuilt, proved all 4 markers absent with --absent on a clean whole tree, and went 11/11 green. Against the pre-fix build the same 11 read 6 red (5 negatives plus the audit-trail case) and 5 green. LINT, a proven narrowing: eslint --no-inline-config --format json over the 4 changed TS files reported 4 files, none ignored, 0 errors and 0 warnings. eslint.config.mjs enables no type-aware linting, so no untouched file's verdict can move; the repo-wide lint is CI's. CI on 0f31443, read once: 7 success, 3 skipped, 22 in_progress, 0 failed. in_progress, not awaited.",
    "gates": "dispatch-gates --repo objectstack-ai/objectstack --commands at 0f31443 (no paths; change set from merge base 2c1cef3): 67 families, the same list as at the pre-merge head 9091023. All 67 were run with exit codes captured before any pipe; the final run of each exited 0. --ran over the record reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. One family first answered PREREQUISITE NOT MET (exit 3): check:dual-build-cjs-loads, because 8 unrelated packages had no dist. They were built under the lock and it re-ran to exit 0; the record keeps the re-run.",
    "line_budget": "594 changed lines (+581 / -13) across 5 files at 0f31443 vs merge base 2c1cef3, under the 5000 human-merge threshold. No skills/** and no governed surface in the diff.",
    "files_changed": [
    ".changeset/21174-admin-audit-metadata.md",
    "packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts",
    "packages/plugins/plugin-auth/src/admin-user-endpoints.ts",
    "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts",
    "packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts"
    ],
    "deviations": [
    "None outside the claim's file surface. Every path is plugin-auth's admin-user-endpoints.ts or its test, a dogfood pin (one new, plus the existing audit-trail pin of this writer, which asserted a dropped value and now reads it from the mirror's update row), or the changeset. No in-lane sibling needed an edit, so nothing outside plugin-auth was declared or edited. plugin-audit, plugin-security and pnpm-workspace.yaml are untouched.",
    "The dogfood fixture uses an objectExtensions entry to replace two platform user-field definitions, giving one written field a mask and one a capability gate. The platform declares neither on those fields. That replace semantics is documented in content/docs/data-modeling/object-extensions.mdx.",
    "Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a different PR footer. Per AGENTS.md and this agent's precedence clause, commits carry the model-free pair (Claude-Session plus Co-authored-by: Claude) and the PR body ends with the session-URL footer the dispatch requires. The pre-push trailer check passed on every push.",
    "Measurement probes: two temporary, never-committed probe files under packages/qa/dogfood/test/ wrote readings into the scratch dir (before, before-with-classes, after). They were moved out before the dogfood commit and copied back only for the after-reading; the tree was clean at every commit.",
    "A read-only blob-filtered clone of objectstack-ai/objectui at the pinned .objectui-sha went into the scratch dir, to name the ledger browser's metadata reader. No write; deleted after reading.",
    "Merged origin/main into the branch once (merge commit 0f31443), with no source overlap. Then pnpm install --frozen-lockfile and a rebuild of the dogfood closure, because the merge moved plugin-audit and core.",
    "The worktree was removed (node_modules first, no --force) after the PR and the label write and before this comment. This comment was posted with the shared checkout's scripts/pm relay tool, used read-only. No AGENTS.md managed block appeared at any point."
    ],
    "mcp_calls": "0 — no MCP GitHub tool called.",
    "api_writes": "3 — each one POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay, executed as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #21195; read-back 12820 bytes sent and 12820 stored, identical); (2) label-write assign → POST /repos//issues/21195/assignees (os-bill; read-back matches; the size/l label on the PR was set by another actor, not this write); (3) this os-dev-report → POST /repos//issues/21174/comments. Not REST: 5 git pushes of the feature branch (the empty-branch probe, then 4).",
    "open_questions": [
    {
    "question": "Ledger rows that the admin endpoints wrote before this release still carry the copied user-field values in their metadata. The ledger is append-only, the fix is producer-side, and read-time narrowing of metadata is ruled out by triage, so nothing in this PR touches them. Should those stored rows be scrubbed?",
    "options": [
    "A: leave them as written. Business need: none new. Long-term: keeps the append-only ledger's bytes untouched, but the measured disclosure persists for every pre-upgrade row, indefinitely, to any reader with a ledger grant. AI error: nothing to author. Startup scope: zero cost.",
    "B: a one-time, producer-owned migration (its own card) that drops the non-decision keys from explicit rows of the two admin events, leaving event, actor, time and reference intact. Business need: real, because every deployment that used the admin create door before the upgrade holds such rows. Long-term: closes the class at rest; the compliance fact (who did what, when, to whom) is unchanged, and the values remain on the mirror rows, narrowed. AI error: a closed, declared key set to keep, not a heuristic. Startup scope: one small migration, but it rewrites an append-only compliance table, which is the maintainer's call.",
    "C: a read-time drop of those legacy keys on rows of the two events. Ruled out by triage 5932908541 as a second derivation of masking. It would also sit in plugin-audit, outside this lane's file surface."
    ],
    "recommendation": "B, as its own card ruled by the maintainer. It is the only option that ends the disclosure for existing rows without a second masking derivation, and it alters no compliance fact. Rewriting an append-only ledger is a security-boundary decision, not a seat's. This PR does not depend on it."
    }
    ],
    "out_of_scope_findings": [
    "carrier: #21175 (this seat, claim 5933258147) · noted, not filed. In the new pin every reader, the control included, is answered 404 by the data plane for the created user, yet is served the ledger rows about it. That is #21175's class (the ledger's parent-record gate); it is named in PR #21195's acceptance notes and stays with that card."
    ]
    }

  4. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT · PR #21195 @ 0f314432 · domain:services seat 2 (#21118) · session_01DiCSbmJrkzNhuEAier4VoJ · 2026-10-01T15:47Z

    已复核报告和 diff:

    Open question(存量行要不要洗)已立卡 #21198,待维护者裁决。本 PR 不依赖它。

    落地: 等全部 check 转绿后,经队列 relay 落地。


    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

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions