Skip to content

An app-declared permission baseline REPLACES the platform member_default instead of composing with it — every built-in Account destination 403s for members of any such app #7555

Description

@huangyiirene

Security-adjacent. The observed failure is fail-closed (403s, no data exposure), but the mechanism — one app's baseline silently displacing the platform baseline for the everyone anchor — is a permission-composition defect, and the same displacement decides what an app's members do keep.

Symptom

A fresh member (purpose-signed-up, no positions beyond the implicit ones) is served the whole Account app and can use none of it:

  • All 10 Account-app nav ids are served to the member.
  • 7/7 of the objects backing those entries answer 403 on read.

So every built-in Account destination is a dead end for that persona, while the nav still advertises all of them.

Expected: either the platform baseline still applies to members of an app that declares its own baseline (so the built-in Account destinations keep working), or the entries whose objects the caller cannot read are pruned from the served nav. Neither happens.

Root cause

The report locates the chain, at this level:

  1. The showcase declares its own baseline set and binds it to the everyone anchor. Boot log, verbatim:

    [security] baseline set bound to everyone anchor (ADR-0090 D5)
    
  2. That binding replaces rather than composes with the platform member_default. Measured breadth: platform member_default grants 23 objects — including all the Account ones — while showcase_member_default grants 7.

  3. Therefore this is not a showcase-specific fixture problem: every built-in Account destination dies for members on any app that declares a baseline.

  4. The second, unprunable half: the Account nav entries are gated with requiresObject, not requiredPermissions, so the nav server has no signal with which to prune an entry whose backing object the caller cannot read. The entry is served regardless of the 403 that follows.

The report does not name the exact code site of the replace-vs-compose behaviour — it locates it at the ADR-0090 D5 everyone-anchor binding. The presumed home is the baseline/permission-set assembly in packages/plugins/plugin-security; confirm the site before fixing rather than assuming it from this card.

Reproduction

  1. Boot the showcase app; confirm the boot log line [security] baseline set bound to everyone anchor (ADR-0090 D5).
  2. Sign up a fresh member and sign in.
  3. Read the served nav for the Account app — all 10 nav ids are present.
  4. For each of the 7 objects backing those entries, GET /api/v1/data/<object> as that member — all 7 answer 403.

Reproduced 2× with two independent member sessions.

Related

Source

Extracted from the QA run #7514 (framework a86db17, console 09987b68).

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim: PM loop round 1 (identity seat, seat post #6022)
    Session: session_01BVc1ekPpi6yaWywAUhfzfd
    Branch: claude/issue-7555-baseline-compose
    Worktree: objectstack-issue-7555 (dev-owned per-task worktree)
    Domain: domain:identity
    File surface: packages/plugins/plugin-security/src/security-plugin.ts (regions: resolvePermissionSetsForContext, the constructor's fallbackPermissionSet derivation at ~:520, and the ADR-0090 D5 everyone-anchor boot binding at ~:2280) · packages/plugins/plugin-security/src/app-default-permission-set.ts · their tests. Stop on breach; explain in the report.
    Container & model: M, mode:cloud (dedicated session per the 2026-08-10 M+ ruling), model: claude-opus-5
    Serial constraints cleared: #7344 is in flight (cross-lane claim by the spec-surface seat, 2026-08-11T05:11Z) with declared surface packages/plugins/plugin-security/src/objects/default-permission-sets.ts + tests — same package, disjoint files/regions (recorded in the seat post's hot-file serial queue). The dispatch instructs a merge of origin/main before the PR opens and again if #7344 lands mid-flight. No other open claim touches these files per this round's read.

    Dispatch rationale (restore-invariant, not escalated — veto window open). ADR-0090 D5 (Accepted) rules the baseline additive: "The fallback cliff is abolished … everyone is additive like any other position: baseline ∪ explicit, always", and narrows isDefault to "a package-authored suggestion consumed once at install time (D5), never a runtime fallback". The current interim wiring (app-default-permission-set.ts, self-described "interim wiring") makes fallbackPermissionSet a single name, so an app's isDefault set displaces member_default — reproducing the exact cliff shape D5 abolishes (declaring an app baseline silently costs members the entire platform baseline). The 2026-08-11 maintainer ruling on #7344 points the same way ("a platform app's platform object belongs to the platform baseline"). Composing instead of replacing restores the stated invariant; it does not widen member_default itself, so #5491's narrowing is untouched. The maintainer can veto here or on the PR.

    Out of scope for this dispatch: the nav-prune half (requiresObject gives the nav server no prune signal) — that is the #7544 sibling family, domain:metadata.


    Generated by Claude Code

  3. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim amendment (same session session_01BVc1ekPpi6yaWywAUhfzfd): Container falls back to mode:subagent — the Claude_Code_Remote MCP tools (create_session) are absent from this PM session after repeated tool-registry searches, and the SKILL's dispatch-backend clause prescribes exactly this fallback when they are unavailable ("if absent, say so and fall back to mode:subagent"). Model tier unchanged: opus. Everything else in the claim stands — branch, worktree, file surface, serial constraints.


    Generated by Claude Code

  4. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Review: ACCEPT — PR #7605 (draft, CI converging; the ready-flip and enqueue follow the gate jobs' real conclusions).

    What shipped, verified against the PR's files (not its summary):

    Maintainer veto window remains open until merge; the dispatch rationale (restore of ADR-0090 D5's "baseline ∪ explicit, always") is recorded in the claim comment above.


    Generated by Claude Code

  5. os-help commented on Aug 11, 2026

    @os-help
    Collaborator
    {
      "issue": 7555,
      "status": "done",
      "branch": "claude/issue-7555-baseline-compose",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7605",
      "premise_still_valid": true,
      "summary": "Premise confirmed on origin/main: SecurityPlugin.fallbackPermissionSet is a SINGLE name, so an app's isDefault set displaces the platform member_default at every site that reads it (the additive step and post-resolution fallback in resolvePermissionSetsForContext, resolveFallbackPermissionSets/ADR-0106 D7, the boot everyone-anchor binding, explain attribution). The human baseline is now the LIST it always was — declared set UNION member_default, deduped — via a pure helper composeHumanBaselinePermissionSets() in app-default-permission-set.ts, applied at all five sites; a new security.baselinePermissionSets service carries it and plugin-hono-server's two resolutions (/auth/me/permissions, /me/apps) read it so the UI plane cannot disagree with the data plane. Hard edges held exactly: the agent branch is taken BEFORE the composed list is consulted so principalKind:'agent' keeps its D10 restricted ceiling; fallbackPermissionSet:null still disables the baseline entirely; member_default's grant rows, #5491's narrowing and the D5/D9 high-privilege anchor gate are untouched. The issue's '23 vs 7' breadth figure is from an older commit and was NOT re-confirmed — the mechanism was verified instead, and the fix does not depend on the counts.",
      "tests": "MEASURED SYMPTOM, before/after on a real booted showcase (fresh sign-up governed by showcase_member_default; same harness, same build path, only composeHumanBaselinePermissionSets ablated to [configured]): sys_account 403->200, sys_session 403->200, sys_api_key 403->200, sys_oauth_application 403->200, sys_user_preference 403->200. sys_inbox_message and sys_notification_receipt read 404 in BOTH columns — not registered on the dogfood showcase stack, so not measurable here and NOT counted as a win. || NEW packages/plugins/plugin-security/src/baseline-composition.test.ts, 11 cases red in four directions (app half / platform half / null half / agent half) plus a guard that member_default really is the only default set granting the discriminator object, so the file cannot go vacuous the way #6964 found its predecessors had. REVERSE VERIFICATION: the same ablation turns exactly 3 of the 11 red — including the runtime case 'the platform half: member_default is STILL in force alongside it' — while the app, null, agent and undeclared-app halves stay green; only the composition-bearing cases move. One case is deliberately NOT a middleware .rejects and says so inline: with zero permission sets the middleware skips its whole CRUD gate by design, so a null-disables case there would be red for an unrelated reason and green either way — it asserts the resolved baseline list instead. || SEMANTICS-FLIP SWEEP: both dogfood displacement pins flipped to assert the composed substance (sys_user_preference -> 200), with their header measurement tables corrected rather than deleted; showcase-d7-default-profile gains a new case proving BOTH sets are bound to the everyone anchor. Refusal pins (agent exclusion, guest tier, anchor gate) verbatim. packages/verify/harness.app-default-profile.test.ts reads the DECLARED name, which still means exactly that — green unchanged. || SUITES (all post-merge of origin/main incl. #7344): plugin-security 46 files / 952 tests passed; plugin-hono-server 16 / 187 passed; rest 82 / 1344 passed; verify 5 / 23 passed; cli 109 / 1182 passed; dogfood FULL gate 87 passed | 1 skipped (88 files), 547 passed | 3 skipped (550 tests). typecheck plugin-security + plugin-hono-server + spec: Done. pnpm check:docs-audit-scope: in sync. node scripts/check-nul-bytes.mjs: OK (7016 files) + targeted grep self-scan clean. CI status at report time: in_progress (reporting at draft-PR time per contract).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #7608: /auth/me/permissions (:740) and /me/apps (:954) still apply the baseline only when the caller resolves to ZERO sets — the same ADR-0090 D5 fallback cliff the data plane abolished, so a member's first explicit grant silently drops the baseline from the UI's object/tab/capability surface while the engine still enforces it. PR #7605 changes WHICH names those sites resolve and deliberately leaves the guard alone; widening it is a distinct behaviour change for every grant-holding user and deserves its own measurement."
      ],
      "notes": {
        "7344_interaction_asked_explicitly": "#7344 LANDED mid-flight (3987a48) and is merged into this branch; every suite above was re-run on top of it (hence 952 rather than 942 plugin-security tests). Does this change make #7344's fix simpler/harder/unnecessary/unaffected? UNAFFECTED in code — files disjoint, clean merge — but this fix is what lets #7344 REACH the persona it was written for: #7344 adds sys_inbox_message read + an owner-scoped RLS carve-out to member_default, and under displacement member_default resolved for NOBODY in a baseline-declaring app (the showcase included), so that grant would have been invisible to exactly the member #7344's report describes. Complementary; neither is sufficient alone for that persona. Reverse direction: #7344 landing changes nothing here — its grants simply join the composed baseline. One named coupling: baseline-composition.test.ts uses sys_user_preference as its 'granted by member_default and nothing else' discriminator and ASSERTS that property rather than assuming it, so #7344's additions keep it true and a future break is reported by name.",
        "d7_decision_asked_explicitly": "resolveFallbackPermissionSets (ADR-0106 D7) composes too, documented at the method. Its entire justification is being the SAME resolution the data plane performs; one plane composing while the other displaced would put the two in disagreement about what the baseline is, which is the drift D7 exists to prevent. No-op for every deployment that declares no app baseline (list is exactly ['member_default']); for one that does, guest schema exposure becomes platform UNION app — both deliberate permission-set decisions, rather than an app declaration silently NARROWING a guest's view relative to a deployment that declared nothing.",
        "changeset": "patch on @objectstack/plugin-security, @objectstack/plugin-hono-server, @objectstack/spec. Patch tier because this restores a stated ADR invariant and can only ADD grants for human principals (most-permissive merge); no API removed, no option changes type, undeclared apps byte-for-byte unaffected.",
        "labels": "no skip-changeset needed — the PR ships a changeset. Bot labels read back on PR #7605: documentation, size/l, tests, tooling."
      }
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Correction to my ACCEPT comment above, flagged by the dev's report and adopted openly: the line "sys_inbox_message remains granted by no shipped set" was true when written (07:37Z) but went stale minutes later — #7344's PR merged mid-flight (3987a48) and member_default now carries owner-scoped sys_inbox_message / sys_notification_receipt grants. The dev merged that into this branch and re-ran every suite on top of it (plugin-security 952 tests, all green); the corrected interplay reading is in PR #7605's body and the os-dev-report comment: the two fixes are complementary — #7344's grants only reach members of baseline-declaring apps because #7605 composes member_default back in; neither alone closes the showcase symptom.

    Also noting for the record: the dev's before/after symptom measurement on a booted showcase (5 Account objects 403→200 under composition, ablation-controlled) — and out-of-scope finding #7608 (the same D5 cliff one plane over, in /auth/me/permissions + /me/apps zero-set guards) filed separately, awaiting triage.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions