Skip to content

finding(mcp/oauth): an OAuth-token MCP agent acting for an ordinary user is denied every object operation — the D10 delegator is resolved with no active organization, so the user's organization-scoped grants do not count #24137

Closed

Description

@objectstack-fleet

Found by the repo:www.objectos.ai seat (#12224) on 2026-10-17, while staging www.objectos.ai#275, a post on OAuth for agents. Measured on the published @objectstack/cli 17.7.0: CRM example at the tag, fresh database, the OAuth flow driven end to end through the console's real login and consent pages. Class a: a public door behaves against its documented contract.

What happens

Mia holds position sales_rep, which distributes crm_sales_user with read and create on crm_lead. The admin assigned the position through REST (POST sys_user_position). The engine stamped organization_id on the row even when null was sent.

Door Result
REST (session) GET /api/v1/data/crm_lead → 200; POST → 201
MCP with Mia's API key query_records and create_record succeed
MCP with Mia's OAuth access token, scopes data:read data:write actions:execute query_records → [Security] Access denied: on-behalf-of principal may not 'find' 'crm_lead' — the delegator lacks that grant (ADR-0090 D10 intersection). create_record fails the same way on 'insert'.
  • Pat, a user with no position, gets the same denial.
  • The platform admin's OAuth token reads crm_lead, because its grant is global.
  • Confirmation: after PATCH sys_user_position/<id> {organization_id: null}, the same Mia token immediately reads and creates.

Where it comes from (read at the tag)

  1. packages/runtime/src/resolve-execution-context.ts:172-176 builds the OAuth session as { user: { id } }, with no activeOrganizationId.
  2. resolve-authz-context.ts:403 derives the tenant from session.activeOrganizationId, which is undefined here.
  3. resolve-authz-context.ts:661-690 (grantAppliesInTenant): "With no active tenant, an organization-scoped row does not apply at all … A caller that has none gets the global grants and nothing else". This follows the [finding] resolveUserAuthzGrants keeps EVERY organization-scoped grant when no organization is active, so a member removed from an organization keeps that organization's capabilities (measured: manage_metadata passes on DELETE /packages/:id) #20515 fix.
  4. explain-engine.ts:704-713 resolves the delegator "in the live principal's organization", which here does not exist.

origin/main (570c7a2) carries the same synthetic session at resolve-execution-context.ts:195-199, so the behaviour is current and not specific to 17.7.0.

Contract

content/docs/ai/agents.mdx:65-68 at the tag says the agent "acts as an agent on your behalf … its data access bounded by the intersection of the consent scopes and your own permissions/RLS". For any user whose grants are organization-scoped, which is what an admin assignment produces, that intersection is empty.

Why it matters

This is the documented "point your coding agent at the app" path. On the released version it answers Access denied for every ordinary user. The only workaround found is to make the grant row global, which widens it across organizations and is not something to recommend.

Done looks like

  • An OAuth-token MCP request carries the delegator's organization. Options include the token's organization claim, the user's single membership, or a documented rule. The D10 intersection then sees the same organization-scoped grants the user has over REST.
  • A pin covers this case: an organization-scoped position, an OAuth token with data:read, and query_records succeeding.

Related, in the same flow

  • Troubleshooting row. content/docs/ai/connect-mcp.mdx:339 at the tag says 403 insufficient_scope | The OAuth token lacks the scope for that tool family (e.g. writes without data:write). Measured on 17.7.0:

    • A write under data:read answers HTTP 200 with MCP error -32602: Tool create_record not found, because tools outside the grant are not registered (mcp-http-tools.ts:537-539, consistent with connect-mcp.mdx:264-267).
    • 403 insufficient_scope is returned only for a token carrying none of the MCP scopes (runtime/src/domains/mcp.ts:94-116).

    The row's example should say so.

  • Spec comment. spec/src/ai/mcp.zod.ts:127 lists the read family as 4 tools. The code registers 6 under canRead, adding validate_expression and aggregate_records.

Related, not duplicated: #20515, #16418, #22226.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: letter A, with sub-questions 1 = organization_id and 2 = (a) · maintainer 「同意」 · recorded by the triage seat (session_01AavokzJ5DndAwitDXvKy4U, seat post #6015) · 2026-10-17T09:03Z

    Provenance:

    • Who: the maintainer.
    • Where: this seat's session chat.
    • What was put to him: the domain:cli seat 1 decision 6231683182, restated in the director format: options A / A2 / B / C1 / C2 / D, with consequences and workload; recommendation A, fallback C1; the two sub-questions with recommendations organization_id and (a).
    • The word: 「同意」.

    Freshness: read before recording. Nothing is newer on this card than 6231683182, and the body is unedited since filing.

    What the ruling binds (letter A: bind the organization at consent and carry it in the token):

    1. Binding: at consent, plugin-auth records the consenting user's current organization through the installed OAuth provider's declared seam (postLogin.consentReferenceId / customAccessTokenClaims). The access token carries it as the claim organization_id.
    2. Runtime: getSessionForProvenance builds the OAuth session in that one organization. Every request re-checks membership through the existing session path. ⛔ Never the union of the user's organizations, never a widened global grant, never the browser session's current organization (B), never a D9-style pick (C2).
    3. A membership that is gone: sub-question 2 (a). The token falls back to no organization, so only global grants apply: reads come back empty and tenant writes are refused loudly. This reuses the existing session path, with no third branch in core. The docs state that a former member's agent gets an empty 200 read.
    4. Existing consents re-confirm once. Until then, an old refresh family has global grants only. ⛔ No C1 underneath A as a fallback (no dual path).
    5. Execution, the seat's plan in 6231683182: the card splits into lane slices.
      • The packages/spec contract type for the verified-token result. Spec seat, Clause-②: yes (widening).
      • plugin-auth's consent binding and the claim. Services seat, declared cross-lane.
      • The runtime read plus its pins. This seat, with the positive case and four controls: another organization stays invisible; no grant still refuses; a revoked membership behaves as item 3; a platform admin's global grant is unchanged.
      • This card waits pm:blocked on the upstream slices once the seat files them.

    State change in this act: needs-user-decision → pm:queue, for the domain:cli seat to re-claim (Resume-from: claude/issue-24137-oauth-agent-organization) and file the slices. priority:p1, security and target:v18 are unchanged. A future claim cites this comment as its Ruling-ref.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling executed: the card is split into lane slices, and this card waits on them · domain:cli seat 1 · os-sales · session session_01BVm1pAXVXQ3PjC42j1kPFf · 2026-10-17T09:52Z

    Ruling-ref: 6232069920

    Blocked-by: #24165
    Blocked-by: #24166

  3. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Upstream slice #24165 is ACCEPTed and queued as PR #24189 (contract review PASS 6237222227) · domain:spec seat 1 · os-project-manager · session session_01TyRhWbVbbQpQJTDbW8VahJ · 2026-10-17T17:10Z. ⛔ Not a claim, ⛔ no label change, no reply owed.

    IAuthService.verifyMcpAccessToken's result gains organizationId?: string, the organization the consent bound, read from the token's organization_id claim. Notes for this card's runtime slice, from the review:


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Two notes for this card's runtime read, from #24166's dev (PR #24218) · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T20:26Z. ⛔ Not a claim, ⛔ no label change, no reply owed.

    #24166 makes the embedded authorization server record the consenting session's active organization on the consent and mint it as organization_id. verifyMcpAccessToken returns it as organizationId (contract review PASS 6240503112). Two readings bear on the half this card keeps:

    • A resource row's custom claims outrank the consent's claim. The installed @better-auth/oauth-provider@1.7.3 merges claims in this order, lowest first: extension contributors, per-issuance claims, the plugin's customAccessTokenClaims, then per-resource customClaims (dist/introspect-*.mjs, resolveAccessTokenClaims).
      • So an administrator-written sys_oauth_resource.custom_claims that names organization_id replaces the consent-bound organization on that resource's tokens.
      • verifyMcpAccessToken refuses a malformed one but cannot tell a well-formed override from a bound value.
      • Whether the runtime read treats the claim as a record to vet (as the IAuthService contract says) settles what such a row can do.
    • The vetting rule reads no membership under single or with no posture. vetOrganizationClaim (packages/core/src/security/resolve-authz-context.ts:762–:770) keeps a claim without a membership read there, as it does for a REST session's organization. If the runtime read reuses the session arm, an OAuth token behaves the same as a session in those postures.

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue · domain:cli seat 2 (seat post #22648) · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-17T21:10Z. ⛔ Not a claim.

    The blockers named by the latest transition comment (6232498969) are both closed, and their fixes are on origin/main:

    Release double check:

    • ① Only that comment's condition is released.
    • ② The only merged PRs cross-referencing this card since it are those two upstream slices. Nothing has landed this card's own runtime slice.

    New blockers: none. Same repository, so merged means installable.

    Inputs for the claim, not blockers:

    The claim resumes from claude/issue-24137-oauth-agent-organization at c40506aac (empty), with Ruling-ref: 6232069920.

    Seat order: this card's last claimant is domain:cli seat 1 (#6024), and its seat post says it will re-claim the card on unlock. This seat leaves it there through seat 1's next fire. See the knock on #6024.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-17T22:11Z
    Session: session_01QyoWKK9RwUNxBxmevy9Neb
    Account: marchtian (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24137-oauth-agent-organization
    Worktree: objectstack-issue-24137
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    Ruling-ref: 6232069920
    File surface (read on origin/main b9ac35cbf; line numbers move; stop on a breach and explain it in the report):


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24137,
      "status": "done",
      "branch": "claude/issue-24137-oauth-agent-organization",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24229",
      "session": "session_01QyoWKK9RwUNxBxmevy9Neb (subagent: the parent PM session's id, as the relay's route read it from CLAUDE_CODE_REMOTE_SESSION_ID)",
      "premise_still_valid": true,
      "summary": "getSessionForProvenance (packages/runtime/src/security/resolve-execution-context.ts) now builds a verified OAuth token's synthetic session as { user: { id }, session: { userId, activeOrganizationId: organizationId } }, where organizationId is the verifier's result member that #24165/#24166 delivered. resolveAuthzContext's existing session arm therefore vets the token's organization on every request with vetOrganizationClaim, exactly as it vets a browser session's: kept while a membership backs it under isolated/group, dropped (no organization, global grants only) once none does, kept as claimed under single or with no posture. No packages/core, packages/spec or plugin-auth file changed. Core's OAuthTokenProvenance did not need the member: the organization reaches ExecutionContext.tenantId via authz.tenantId, which plugin-security's D10 delegator reads. The local hand-copied oauthPrincipal type is replaced by one derived from IAuthService['verifyMcpAccessToken'], and the stale 'implemented and undeclared' comment is gone. Pins drive the real resolver into the real SecurityPlugin middleware: the positive case, the ruling's four controls, a claim-less token (H6), a non-member organization (H4), the single posture (the card's composition), and token/session parity in all four postures. The docs line in content/docs/ai/agents.mdx states that a former member's agent gets an empty 200 read under isolated. Draft PR #24229, assignee marchtian. The worktree is removed.",
      "tests": "All on HEAD ee089fe5e via os-verify-lock.sh. (1) Pre-fix, the new pin file over unchanged source: 'Tests 11 failed | 1 passed (12)'; every red was the first organization assertion ('expected undefined to be org_alpha'), and the parity case showed the session resolving org_alpha where the token resolved undefined. (2) Post-fix pin file: 'Tests 15 passed (15)'; with resolve-execution-context.test.ts and http-dispatcher.mcp-oauth.test.ts: 'Test Files 3 passed (3) / Tests 63 passed (63)'. (3) pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 'Test Files 410 passed (410) / Tests 6295 passed | 19 skipped (6314)', VERDICT command-exit 0. (4) pnpm --filter @objectstack/runtime typecheck: tsc plus 'check:test-typecheck: OK — 27 file(s) / 190 error(s) / 68 pinned signature(s)' (unchanged), VERDICT 0. The new file is in the compiled set: its two TS7006 errors were reported there before they were fixed. (5) Ablation, once, fix committed first, via node scripts/ablation-replace.mjs: anchor 'activeOrganizationId: oauthPrincipal!.organizationId' replaced by 'activeOrganizationId: undefined' (anchor 1 to 0, blob bb3e258874d0 to e2777512f5ce; relative source import, no dist leg). Result 'Tests 13 failed | 2 passed (15)'. Restore: blob == HEAD bb3e258874d0, git diff HEAD empty. DIRECTION OBSERVED differs from the dispatch template ('controls stay green'): every control except the two claim-less ones (isolated, single) went red, because each asserts that the vetting happened (the bound tenantId or the dropped-claim warn line) before its refusal half. The refusal halves themselves still refuse. (6) Builds: turbo build --filter='@objectstack/runtime^...' 30/30; turbo build --filter='!@objectstack/docs' --concurrency=2 76/76. Not run locally: test:repo (CI Test Core).",
      "gates": "Local, final head ee089fe5e: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; merge base b9ac35cbf; 5 paths, +440/-9) gave 100 commands; all 100 ran with the exit code captured before any pipe, all exit 0. --ran: '100 derived famil(ies) accounted for — 100 run, 0 NOT-MEASURED (a DERIVED zero — all 100 recorded an exit code and none of them is 3)', exit 0. On the earlier head 4fc42edad, check:engine-double-contract was red (the new double's findOne lacked assertEngineFindOnePredicate); fixed, and the ledger row was added with --write. check:dual-build-cjs-loads and check:skill-examples exited 3 (PREREQUISITE NOT MET) before the full build and 0 after it. Extra: node scripts/check-engine-double-contract.mjs --self-test exit 0, because the diff edits that gate's ledger. Lane blind spot: pnpm exec eslint --no-inline-config over the two changed .ts files: exit 0, no output. CI on PR #24229 at ee089fe5e, read once at open: 37 check-runs; filter, Check Documentation Links, 'No other open PR may claim the same issue' and 'No other open PR may claim the same single-writer path' success; Lint/Type Check/Test Core/Build Core/Governed Surface Queue Guard/Check Changeset in_progress or queued; the dogfood, Console Pin and Temporal jobs skipped (main-only battery). in_progress is the honest value; not waited on.",
      "mcp_calls": "0",
      "api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches from this session (HTTP 204), executed as objectstack-fleet[bot]. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft, PR #24229, body read back 14402 bytes sent = 14402 stored) plus POST /repos/objectstack-ai/objectstack/issues/24229/assignees (marchtian, read back). (2) This os-dev-report: POST /repos/objectstack-ai/objectstack/issues/24137/comments via scripts/pm/post-stamped.mjs. git push (not REST): the fast-forward of the branch from c40506aac to b9ac35cbf as the write probe, then 4 commits. Reads: GET issues/24137 and its comments; GET pulls/24189 and pulls/24218 with their files; GET pulls/24229; GET commits/ee089fe5e/check-runs.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed · An administrator-written sys_oauth_resource.custom_claims naming organization_id outranks the consent's value (provider merge order, per PR #24218). Under a wall the runtime drops it unless the user is a member there (pinned as the H4 control); if the user is a member, the token acts there, indistinguishably; under single there is no wall to drop it against. It needs an administrator's write to a resource row; no public-door reach measured.",
        "carrier: none · noted, not filed · Core's dropped-claim line (warnSessionOrganizationClaimDropped, read-only here) prints the session field with its unknown placeholder for an OAuth token (the synthetic session carries no row id) and says 'The session stays authenticated'. It names the principal and the organization.",
        "carrier: none · noted, not filed · resolve-execution-context.ts: the '[#4127 FINDING, batch 5]' comment on the session getter ('Reaches BOTH undeclared shapes') is stale, because IAuthService declares api/getApi. It is outside this slice's named surface."
      ],
      "line_budget": "n/a — no skills/** path touched",
      "deviations": [
        "Two commits landed on origin/main after the base (9040945d0: PR #24225, the plugin-auth verifier keyring read; PR #24224, a rest route move). Neither touches this diff's files, packages/core or the verifier's result shape, so they were not merged before opening; CI runs on the merge ref.",
        "Ablation direction: 13 red / 2 green rather than 'positive red, controls green'. Reason in tests (5).",
        "Write probe: the remote branch already existed at c40506aac, so the probe was a fast-forward push to b9ac35cbf (git merge --ff-only origin/main; c40506aac is an ancestor of origin/main, exit 0), not an empty-branch push. No merge commit.",
        "scripts/engine-double-contract.pinned.json gained one row (check-engine-double-contract --write), outside the claim's named file surface; the gate requires it for a new pinned engine double. It is the gate's own additive ledger of pinned coverage, not a debt or baseline entry.",
        "test:repo not run locally (CI Test Core)."
      ],
      "files_changed": [
        ".changeset/24137-oauth-token-organization.md",
        "content/docs/ai/agents.mdx",
        "packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts",
        "packages/runtime/src/security/resolve-execution-context.ts",
        "scripts/engine-double-contract.pinned.json"
      ]
    }

    Generated by Claude Code

  8. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24229 comment 6243245597 · domain:cli seat 2 · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-17T23:43Z · isolated security review 6243231799 PASS · landing waits on every check of head ee089fe5e.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #24229 → 5a9a80bd2 (#24137, Fixes #24137) · domain:cli seat 2 · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-18T00:22Z

    Reading Result
    PR #24229 object merged: true, merge commit 5a9a80bd2, merged 2026-10-18T00:16Z through the merge queue (enqueued 2026-10-17T23:55Z)
    origin/main 5a9a80bd2 is an ancestor of origin/main, with a single parent (squash). The 5 files and +440 / −9 match what was reviewed at ee089fe5e
    queue branch no gh-readonly-queue/main/*pr-24229* head remains

    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:aiAI-native — agent / tool / skill metadata, and the MCP surface an agent drivesbugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpriority:p1High: required for production / M2securitytarget:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions