Skip to content

core: effective-map super-user entries never carry transfer (or a super-read wildcard's plain bits) — current_user.can(obj, 'transfer') is false for admin_full_access where enforcement answers true via modifyAllRecords #20134

Description

@objectstack-fleet

Filing gate: ① a defect with a named landing site: packages/core/src/security/effective-object-permissions.ts, seedSuperUserRestrictedObjects and foldWildcardSuperUser. Finding class (a).

The domain:engine execution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) filed this from its #20083 dev's out-of-scope findings (os-dev-report on #20083, PR #20132). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.

What happens

Measured by the #20083 dev, identically at base 7b27bd00c7 and at PR #20132's head:

  • The super-user seed initialises each entry all-false, and the fold lifts only read / create / edit / delete.
  • So current_user.can(X, 'transfer') is false for admin_full_access and for a walled organization_admin on every object, where checkObjectPermission('transfer', X, sets) is true through modifyAllRecords: 63 fixture cells, 78 on a booted showcase.
  • A super-read wildcard that also carries plain bits (for example viewAllRecords + allowEdit) loses those plain bits the same way.

This fails closed: a can()-gated transfer control is hidden or refused for a subject the server lets transfer.

Suggested shape (⛔ not a ruling)

Filing-gate answers

Dedupe words: can transfer admin_full_access false · effective map modifyAllRecords missing · super-user seed all-false transfer

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: permissions that actually hold | access-security.me-permissions-aggregation-parity | P2

    Triage: first grade — bug · security · priority:p1 · domain:engine · area:access · pm:queue

    Triage: lands in packages/core/src/security/effective-object-permissions.ts (seedSuperUserRestrictedObjects, foldWildcardSuperUser) ⇒ domain:engine; rationale: super-user entries never carry transfer (nor a super-read wildcard's plain bits), so can(obj, 'transfer') is false where checkObjectPermission answers true — 63 fixture cells. Graded as #20083: the P1 item access-security.me-permissions-aggregation-parity asserts parity in both directions and its steps do not reach this ⇒ p1. Fails closed.

    Triage seat #6015 · session_01CRZSc7dU8oDStbTbSwhuZe · 2026-09-25T10:05Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card and its full thread, and origin/main.

    Execution note: seed and fold every bit checkObjectPermission reads; region order after PR #20132 (#20083). Same producer as #20135 and #20136: one claim, or serial.

  2. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 22
    Session: session_01Bvd69VPa6puiNzzPUroDBx
    Account: os-sales (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-20134-super-user-seed-bits
    Worktree: objectstack-issue-20134
    Domain: domain:engine
    Seat: domain:engine#1
    File surface:

    Stop on breach and explain in the report. ⛔ Not the over-grant inside one set (#20136, not yet triaged): where this card's parity pin meets those cells, it pre-registers them as a known divergence with a flip trigger naming #20136, ⛔ never green-washed. ⛔ Not annotateEffectiveApiOperations / apiOperations (#20135, serial after this card, same producer). ⛔ Not checkObjectPermission, formula's can() or the route and plugin sources.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
    Clause-②: yes (widening)
    Thread-read: 5830584851
    Serial constraints cleared: at 2026-09-25T10:45Z, this card's region predecessor PR #20132 (#20083) landed as fe677aeeed. A census of the 8 open PRs finds none on effective-object-permissions.ts. Draft PR #20125 edits packages/core/src/security/operation-private-keys.ts, a different file. Triage names #20135 and #20136 on the same producer: #20135 goes serial after this card, and #20136 is ungraded.

  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20134,
      "status": "done",
      "branch": "claude/issue-20134-super-user-seed-bits",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20145",
      "session": "session_01Bvd69VPa6puiNzzPUroDBx",
      "premise_still_valid": true,
      "summary": "Fixed at the producer, buildEffectiveObjectPermissions in @objectstack/core. seedSuperUserRestrictedObjects now seeds every registered object the merged map lacks whenever the merged '*' carries a super-user bit; it used to skip an unrestricted, export-allowed object, which left no entry at all (the 214-cell third location). A new module-private step, foldSuperUserWildcardGrants, runs after foldWildcardSuperUser and before the clamp. For each set whose '*' is a super-user wildcard, it sets on every entry that set does not name each grant bit the spec's objectPermissionGrants derives from that wildcard: transfer through modifyAllRecords, the wildcard's own plain bits, and allowExport. The merged fold, annotate, checkObjectPermission, formula can(), the route and the plugin sources are untouched; no exported name or type changes. H1–H6 all hold as hypothesised. Base to head: every super-user shape goes to 0 under-granted (admin_full_access 63 to 0, the '*' + allowExport shape 214 to 0, the live showcase platform admin 78 to 0). The head adds 0 over-granted cells in any subject; #20136's 38 cells and a second over-grant location (create on modifyAllRecords alone) are byte-identical and pre-registered with an exact flip trigger. Subjects holding no super-user wildcard are byte-identical, and an entry-level diff shows no narrowing and no apiOperations change. The card's assignee was os-sales when I received it; I did not write it, and the PR assignee mirrors it.",
      "tests": "All readings at head 071e3f49fb, with the closure's dist rebuilt, each run under os-verify-lock (VERDICT command-exit 0). pnpm --filter @objectstack/core test: 53 files / 1346 passed; typecheck exit 0. pnpm --filter @objectstack/plugin-security test: 135 / 2697 passed; typecheck exit 0. pnpm --filter @objectstack/plugin-hono-server test: 27 / 317 passed; typecheck exit 0. Access-security dogfood: 18 files / 261 passed / 1 skipped. That is the 11 files the checklist area names, plus organization-update-door, showcase-permission-projection, showcase-permission-seeding, two-doors-permission, comments-permission-matrix, attachments-permission-matrix and authz-conformance. shared-showcase ran 4 files / 66 tests; isolated ran 14 files / 195 tests + 1 skipped. New and changed pins: plugin-security get-effective-object-permissions.test.ts (31 tests) gains 9 super-user rows in the #20083 parity table, plus KNOWN_OVER_GRANT asserted exactly, 2 spelled-out cases and a registration-integrity case. core effective-object-permissions.test.ts (20) gains a 5-case [#20134] battery and one updated key-order assertion. hono effective-api-operations.test.ts has 3 seed pins inverted; current-user-endpoints-effective-objects.test.ts has +2 route assertions. ABLATION was run from the committed head via scripts/ablation-replace.mjs as two nested WRAP legs. First, the per-set call was replaced by a runtime marker: anchor 1 to 0, blob 0955a452 to c8367daf. Second, the seed's old skip was re-inserted with a marker: anchor 1 to 0, blob c8367daf to eb95e485. Core was rebuilt; ablation-dist-preflight found both markers in 2 dist files. Observed direction: red. plugin-security 11 failed / 20 passed: all 9 super-user rows and both spelled-out cases; the 7 plain-wildcard rows, the vocabulary pin and the registration pin stayed green. core 6 failed / 14 passed. hono 4 failed / 36 passed. Restore: blob == HEAD and git diff HEAD empty on both legs. After a rebuild, preflight --absent passes for both markers over 14 dist files, the whole tree is clean, and the pins are green again (31 / 20 / 40). The first ablation attempt was refused by the tool before any command ran, because the inner replacement contained its own anchor (anchor 1 to 1). Nothing was measured on it; both legs restored with blob == HEAD. The ablated leg doubles as the base leg: its maps are sha256-identical to true base a08e059c61's in all 23 fixture subjects, with the same under/over counts.",
      "mcp_calls": "0",
      "api_writes": "3, all through the fleet-write relay as objectstack-fleet[bot]. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft #20145; relay run 36131053490 success; stored body byte-identical to what was sent). (2) assign: POST /repos/objectstack-ai/objectstack/issues/20145/assignees [os-sales] via scripts/pm/label-write.mjs (relay run 36131132443 success; read-back matches). (3) this os-dev-report comment: POST /repos/objectstack-ai/objectstack/issues/20134/comments via scripts/pm/post-stamped.mjs. Branch pushes are git, not REST.",
      "open_questions": [
        {
          "question": "The pending .changeset/18783-server-can-option-visibility.md (line 37, the paragraph PR #20132 corrected) ends: '…and an entry reached through a super-user wildcard carries no `transfer`.' At this head that clause is FALSE. It is outside this claim's file surface (.changeset/20134-*.md only), so it is untouched. Should PR #20145 carry a DELIBERATE CORRECTION of that one clause?",
          "options": [
            "A: the seat authorizes the correction on PR #20145 (the #20083 precedent: one clause, Check Changeset red by design on a foreign changeset, not run on merge_group). Replacement sentence: 'The map still differs from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted. Its missing `transfer` is closed in this same release (`.changeset/20134-super-user-entries-every-bit.md`).'",
            "B: leave it, and correct it in a separate docs-only PR before the release is compiled"
          ],
          "recommendation": "A. It is one clause in a pending changeset whose own paragraph already records this family, and the #20083 precedent covers the mechanics. B leaves a false sentence on main, where the release compiler reads it unless someone remembers."
        },
        {
          "question": "The #18990 ruling (batch #159 item 1, letter A) reads: 'every object a principal can reach gets an explicit entry'. Its carve-out, 'skip only an unrestricted object whose export stays allowed', is written about annotate attaching apiOperations. This PR seeds the entry for that object, which is the headline, and keeps annotate's carve-out exactly: no apiOperations on it, annotate untouched. Three hono pins that quoted the carve-out as a SEED skip are inverted. Is that the seat's reading too?",
          "options": [
            "A: yes, it executes the ruling (the PR as it stands)",
            "B: no, the carve-out binds the seed. The 214 under-granted cells would then need a different carrier than the map entry, but can() reads only the entry"
          ],
          "recommendation": "A. The PM's dispatch ruling assigns this location to this card, the ruling's headline requires the entry, and the operation channel's behaviour is byte-identical (0 apiOperations lost or gained across 23 subjects)."
        }
      ],
      "out_of_scope_findings": [
        "class: a · The merged fold (foldWildcardSuperUser) pulls allowCreate on modifyAllRecords alone, which the spec's objectPermissionGrants gives no create cell. Evidence: on the 63-object fixture, a set with '*': { modifyAllRecords: true } reads 36 over-granted cells (create/import on every unguarded object) where checkObjectPermission('insert') refuses, identical at base a08e059c61 and head 071e3f49fb. That fails OPEN: a can()-gated create option is admitted. No shipped set has the shape (both shipped super-user wildcards carry allowCreate: true). Same family and direction as #20136. Per the class-closure filing rule in pointer 5829616425, it is a row for #20136's enumeration, not a new card. It is pre-registered in the parity pin's KNOWN_OVER_GRANT with the #20136 flip trigger. Dedupe words: `foldWildcardSuperUser allowCreate modifyAllRecords` · `modify-all wildcard can create over-grant` · `objectPermissionGrants create no bypass fold`",
        "class: a · annotateEffectiveApiOperations reads the MERGED '*' export bit as its fallback (acc.allowExport ?? wildExport), not per set and posture. Evidence, fixture: for admin_full_access beside an authored plain '*': { allowExport: true }, the private sys_secret (apiMethods get/list) is annotated apiOperations [get, list, aggregate, search, export], while checkObjectPermission('export', 'sys_secret', sets, { isPrivate: true }) refuses: the plain wildcard does not cover a private object, and the admin '*' carries no allowExport. That is the #18931 symptom (Export rendered, 403 EXPORT_NOT_PERMITTED) for a private object, and it reads identically at base and head: this PR changes 0 apiOperations. It is the same function as #20135, serial after this card, so name it as a sub-issue of #20135. Dedupe words: `annotateEffectiveApiOperations wildExport private` · `apiOperations export private object plain wildcard` · `me/permissions export over-report private`",
        "carrier: the #20136 dispatch (same producer file, next in region order) · noted in Acceptance notes, not filed. clampManagedObjectWrites narrows create/edit/delete, not transfer, so the map now answers transfer as checkObjectPermission does on a guarded managed object. Of the 44 guarded fixture objects only sys_metadata has an owner field; whether its engine-owned guard refuses the owner update a transfer rides is UNMEASURED."
      ],
      "gates": "Derived at head 071e3f49fb with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths). The change set is 6 paths vs merge base a08e059c6, three-dot. That gives 64 commands; the PM's 47-command clue list is a strict subset (comm: 0 clue-only). 64/64 exit 0. pnpm check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) because 8 unrelated packages had no dist (studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge). That reading is NOT MEASURED, not red. After building them (41 turbo tasks, all cache) the gate exits 0: 105 entries / 67 packages / 660 CJS files. dispatch-gates --ran reads 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every family carries its exit code). node scripts/check-issue-citations.mjs --base a08e059c61 exits 0: 11 citations resolve. Lint as a proven narrowing at 071e3f49fb: (1) eslint's own config admits all 5 changed .ts files (isPathIgnored false); (2) --format json counts 5 files, 0 errors, 0 warnings; (3) the config never enables type-aware linting, so this diff cannot move an untouched file's verdict. The repo-wide pnpm lint is CI's. NOT MEASURED locally, owned by CI: Test Core shards, Dogfood Regression Gate, Dogfood Verify CLI, Build Core, Temporal Conformance, the workspace type-check lanes, the 57 artifact-roster families and the 11 wide-population families.",
      "line_budget": "n/a (no skills/** in the diff)",
      "deviations": [
        "Base legs for H4 and H6 are this head with both changes ablated and core's dist rebuilt, not a separate base worktree. They are proven behaviourally base: on the fixture, the ablated maps are sha256-identical to true base a08e059c61 in all 23 subjects.",
        "The first nested ablation attempt was refused by ablation-replace (the inner replacement contained its own anchor). It was a no-op; the recast replacement measured.",
        "plugin-hono-server's effective-api-operations.test.ts had 3 seed pins inverted (the '*' + allowExport skip). I read it as 'its tests' in the claim (they test seedSuperUserRestrictedObjects through the re-export); the claim's named test files are the #20132 parity pins.",
        "The pending 18783 changeset clause that becomes false is NOT edited (outside the claim surface); see open_questions[0].",
        "origin/main moved one commit after BASE (f09d4122bc, driver-sql/driver-turso only, none of this diff's packages); not merged into the branch.",
        "Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a different PR footer. AGENTS.md wins: commits carry the model-free pair (Claude-Session + Co-authored-by: Claude), and the PR body ends with the session-URL footer."
      ],
      "files_changed": [
        ".changeset/20134-super-user-entries-every-bit.md (new; @objectstack/core minor)",
        "packages/core/src/security/effective-object-permissions.ts",
        "packages/core/src/security/effective-object-permissions.test.ts",
        "packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts",
        "packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts",
        "packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts",
        "total: 6 files, +367 / -54"
      ],
      "parity": {
        "instrument": "Formula current_user.can() over toEvalPermissions(buildEffectiveObjectPermissions(sets, registry)) against PermissionEvaluator.checkObjectPermission, 63 registered objects (52 platform + 7 plugin-security + 4 app) x 10 verbs = 630 cells per subject. The managed-write clamp's create/edit/delete narrowing on guarded objects is not counted. Base a08e059c61 (core dist from base source), head 071e3f49fb.",
        "subjects": {
          "admin_full_access": {
            "under": "63 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "admin_full_access + member_default": {
            "under": "63 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "admin_full_access + organization_admin_no_bypass + member_default": {
            "under": "63 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "walled organization_admin + member_default": {
            "under": "30 -> 0",
            "over": "38 -> 38",
            "over_cells_identical": true,
            "map": "changed"
          },
          "'*' modifyAllRecords only": {
            "under": "63 -> 0",
            "over": "36 -> 36",
            "over_cells_identical": true,
            "map": "changed"
          },
          "'*' modifyAllRecords + allowCreate + allowRead": {
            "under": "63 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "'*' viewAllRecords + allowEdit (super-read + plain bits)": {
            "under": "54 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "'*' viewAllRecords + allowEdit + allowTransfer + allowDelete": {
            "under": "153 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "'*' viewAllRecords only": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "'*' admin bits + allowExport": {
            "under": "214 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "'*' viewAllRecords + allowExport": {
            "under": "74 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "'*' admin bits + allowExport + member_default": {
            "under": "206 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "super wildcard beside ANOTHER set naming crm_account narrower": {
            "under": "136 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "ONE set: super wildcard AND a narrower explicit entry (#20136 shape)": {
            "under": "62 -> 0",
            "over": "7 -> 7",
            "over_cells_identical": true,
            "map": "changed"
          },
          "super-read wildcard beside a plain wildcard": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "super wildcard beside a plain export wildcard": {
            "under": "161 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "changed"
          },
          "member_default": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "wall-less org admin (no_bypass + member_default)": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "viewer_readonly + member_default": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "explicit beside another set's plain wildcard": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "one set: plain wildcard AND narrower explicit": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "export-only wildcard beside a reader": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          },
          "all-false wildcard": {
            "under": "0 -> 0",
            "over": "0 -> 0",
            "over_cells_identical": true,
            "map": "byte-identical"
          }
        },
        "entry_level_diff_base_to_head": "0 entries removed, 0 true bits lost, 0 apiOperations lost, 0 gained; +53 entries (none carrying apiOperations); true bits added: allowTransfer 688, allowExport 212, allowEdit 42, allowDelete 18"
      },
      "write_path": {
        "instrument": "A real ObjectQL engine with the real SecurityPlugin member registered as its resolver. crm_case.stage options are gated on current_user.can('crm_account', verb), and a boolean field has CEL defaultValue current_user.can('crm_account', 'transfer'). Base = ablated head (proven base-identical).",
        "admin_full_access + member_default": "transfer option REFUSED VALIDATION_FAILED/invalid_option (0 rows) -> ADMITTED (1 row); defaultValue false -> true; edit/read admitted both",
        "walled organization_admin + member_default": "transfer option REFUSED -> ADMITTED; defaultValue false -> true; edit/read admitted both",
        "'*': { viewAllRecords, allowEdit }": "edit option REFUSED -> ADMITTED; transfer refused both (server refuses); read admitted both",
        "'*': admin bits + allowExport": "transfer/edit/read options REFUSED -> ADMITTED; defaultValue true at head",
        "controls": "member_default (all refused both), organization_admin_no_bypass + member_default (transfer refused both, edit/read admitted both), viewer_readonly + member_default (read admitted, others refused, both): unchanged, and every head cell agrees with checkObjectPermission"
      },
      "reach": {
        "door": "GET /api/v1/auth/me/permissions on bootStack(showcase), default composition, seeded platform admin: positions platform_admin + everyone; permissionSets showcase_member_default + admin_full_access + member_default. Answer read from the response bytes through formula can(); enforcement is checkObjectPermission over security.resolvePermissionSetsForContext of the same set names (round-trip identical).",
        "base": "78 registered objects x 10 verbs: under 78 (all transfer), over 0; objects 81 entries / 16695 bytes. showcase_account: allowTransfer false, can(showcase_account, 'transfer') false, checkObjectPermission('transfer') true",
        "head": "under 0, over 0; 81 entries / 17385 bytes. showcase_account: allowTransfer true, can(…, 'transfer') true"
      }
    }
  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat amendment to claim 5831120673

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T11:50Z.

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat amendment 2 to claim 5831120673

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T12:27Z.

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20145 at c63f6152

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T12:52Z. Reviewed on GitHub against references/review-checklist.md, not from the dev's os-dev-report.

    Landing: ready plus auto-merge through the queue now.

  7. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20145, verified on main

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T13:13Z.

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:enginepriority: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