Skip to content

feat(gateway): record session owners and restrict writes to owner or admin - #260

Merged
Telli merged 9 commits into
mainfrom
feat/session-ownership
Sep 29, 2026
Merged

Telli merged 9 commits into
mainfrom
feat/session-ownership

Conversation

@Telli

@Telli Telli commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Description

Stacked on #259 (← #257). Merge those first, then retarget this PR to main.

Sessions are keyed only by id (SessionManager.GetOrCreateByIdAsync). Any operator who knew or guessed a session id could post into another account's conversation. Mobile clients, for example, use client-generated session ids. #259 made each turn run as the right account; this PR decides whether that account may post to the session at all.

Policy, as agreed:

  1. Writes: owner or admin only.
  2. Reads: unchanged; any role that can read sessions still can, so dashboards and audit are unaffected.
  3. Unowned sessions: sessions from channels, cron, or before this change stay unowned and open. Writing to one never claims it.

Summary

  • Session.OwnerAccountId: set once, when a signed-in account creates the session. That covers the pipeline (from InboundMessage.AuthenticatedUserId), /v1/*, /apps/chat, and A2A. The owner is never reassigned. It lives in the session JSON, so older sessions simply have none.
  • SessionAccess.CanWrite(session, accountId, isAdmin): writes are allowed if the session is unowned, the caller is the owner, or the caller is an admin. Callers with no account are also allowed: bootstrap, open loopback, and channel/system turns. The first two are admin-equivalent; channel and system turns address sessions by their own keys.
  • Enforcement on every write surface:
Surface Refusal
POST /api/integration/messages 403 before queueing, based on the same effective session id the facade derives
MCP openclaw.send_message Tool error before queueing
Pipeline turns, including /ws and anything already queued Reply "This conversation belongs to another account." and skip the turn
POST /apps/chat 403
A2A (task ids can name existing sessions) Error event; the agent does not run
/v1/* No check needed: sessions are already scoped to the calling credential. They now record the owner
  • Admin flag: admin status travels on InboundMessage.AuthenticatedUserIsAdmin and A2ACallerContext.IsAdmin, because OIDC admins are not in the account store. WebSocketChannel.HandleConnectionAsync gains an authenticatedUserIsAdmin parameter.
  • Listing: GET /api/integration/sessions?owner=me lists the caller's own active and persisted sessions. It uses an owner filter in FileMemoryStore, SqliteMemoryStore (json_extract), and the shared summary/session filters. SessionSummary gains ownerAccountId, and callers without an account own none.

Behavior changes

  • An operator can no longer post into a session another account created. Admins still can.
  • Pre-existing sessions have no owner and behave as before.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • New feature (owner=me filter)
  • Documentation update
  • Tests

Validation

  • dotnet build OpenClaw.Net.slnx --configuration Release: 0 warnings, 0 errors
  • dotnet test OpenClaw.Net.slnx --configuration Release --no-build: OpenClaw.Tests 3,156 passed / 11 skipped / 0 failed; NacosLiveAcceptance 25 passed; LayaService 67 passed
  • dotnet run --project samples/OpenClaw.HelloAgent -c Release --no-build

New tests failed first for the right reason:

  • the owner wasn't recorded;
  • another account's REST, MCP, /apps/chat, A2A, and worker writes succeeded;
  • owner=me returned everything;
  • both stores ignored the owner filter.

The guards cover the owner and admins still writing, unowned sessions staying open, and callers without an account being unrestricted.

Review Notes

  • I considered NativeAOT compatibility (no reflection; the new properties go through CoreJsonContext)
  • I considered security posture and unsafe defaults. This changes authorization, so it needs core maintainer review.
  • I updated docs/tests where needed: docs/AUTHENTICATION.md §3.6 (with zh-CN) and CHANGELOG.md
  • This PR is scoped and does not mix unrelated changes

Commercial or Customer-Driven Contribution Disclosure

Same origin as #256: surfaced during a spec review for AgentQi Mobile, which needs "only my conversations". Vendor-neutral gateway hardening.

Checklist

  • I have read the CONTRIBUTING guidelines
  • My code follows the code style implementation of this project
  • I have added tests that prove my fix is effective or that my feature works
  • All new and existing tests passed locally (dotnet test)
  • I have updated the documentation (README.md, comments) if required
  • I have checked for security implications (input validation, authorization)
  • I have checked the relevant maintainer review checklist
  • I have disclosed whether this directly supports a company or customer use case

🤖 Generated with Claude Code

Telli and others added 5 commits September 28, 2026 14:54
Account-token verification runs PBKDF2 (120,000 iterations, about 17 ms
of CPU) inside OperatorAccountService's global lock and then rewrites
the accounts file. The role checks added in this branch meant that
/v1/*, /apps/chat, A2A, /ws, /ws/live, and mutating MCP tools verified
the same token twice per request, halving account-token throughput on
those surfaces.

Cache the verification outcome in HttpContext.Items for the rest of the
request. Revocation, disabling, and role changes still apply from the
next request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
/ws/live admitted any authenticated role. It runs no tools, but it opens
a live session on the gateway's provider credentials, so a viewer token
could spend them. It now uses CanExecuteAgent like /ws and closes below
operator with 1008.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Companion opened the chat socket before loading the account's role, so
a viewer was admitted and then closed by the gateway. It now loads the
role first; when the gateway reports a role below operator it explains
that chat needs the operator role instead of connecting, and keeps the
read-only status views. A role that is only the no-token placeholder
does not block the connection, and the role is applied as soon as the
auth session loads rather than after the setup status call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Session.AuthenticatedUserId scopes per-user capability bindings and is
passed to MCP servers as _meta.userId, falling back to SenderId. Only
/ws set it, so turns from REST messages, MCP send_message, A2A, /v1/*,
and /apps/chat ran as whatever senderId or A2A contextId the caller
supplied.

- EndpointHelpers.ResolveAuthenticatedAccountId resolves the signed-in
  account (reusing the per-request token verification). REST and MCP
  stamp it on queued messages; /v1/*, /apps/chat, and the A2A bridge
  set it on the session. A2A captures it in middleware through an
  async-local, so detached SDK work still sees it.
- The worker no longer lets an account-less external turn inherit the
  previous writer's account: after an operator posted into a Telegram
  session, the Telegram user's turns ran as that operator. System,
  scheduled, automation, and background-continuation turns still act on
  the session's behalf and keep its identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…admin

Sessions are keyed only by id, so any operator who knew a session id
could post into another account's conversation.

- Session.OwnerAccountId is set once, when a signed-in account creates
  the session (pipeline, /v1/*, /apps/chat, A2A), and never reassigned.
- SessionAccess.CanWrite: owned sessions accept the owner or an admin;
  unowned sessions (channels, cron, older data) stay open and are never
  claimed by writing to them; callers without an account (bootstrap,
  open loopback, channel and system turns) are not restricted.
- Enforced on every write surface: REST and MCP send_message refuse
  before queueing (403 / tool error), the worker refuses pipeline turns
  including /ws with a reply, /apps/chat returns 403, and the A2A bridge
  emits an error event. Admin status travels with the message because
  OIDC admins are not in the account store.
- Reads are unchanged. GET /api/integration/sessions?owner=me lists the
  caller's own sessions, backed by an owner filter in the file and
  SQLite stores and in the shared summary filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 28, 2026 22:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 2d81d7ba-b3ee-45b6-a9fb-d509417e2fe7

📥 Commits

Reviewing files that changed from the base of the PR and between 03d9a76 and 0b0e448.

📒 Files selected for processing (40)
  • CHANGELOG.md
  • docs/AUTHENTICATION.md
  • docs/zh-CN/AUTHENTICATION.md
  • src/OpenClaw.Channels/WebSocketChannel.cs
  • src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs
  • src/OpenClaw.Core/Memory/FileMemoryStore.cs
  • src/OpenClaw.Core/Memory/SqliteMemoryStore.cs
  • src/OpenClaw.Core/Models/AdminApiModels.cs
  • src/OpenClaw.Core/Models/GatewayConfig.cs
  • src/OpenClaw.Core/Models/Messages.cs
  • src/OpenClaw.Core/Models/Session.cs
  • src/OpenClaw.Core/Models/SessionAdminModels.cs
  • src/OpenClaw.Core/Sessions/SessionAccess.cs
  • src/OpenClaw.Core/Sessions/SessionManager.cs
  • src/OpenClaw.Gateway/A2A/A2ACallerContext.cs
  • src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs
  • src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs
  • src/OpenClaw.Gateway/Composition/IntegrationApiFacade.cs
  • src/OpenClaw.Gateway/Composition/SessionAdminListing.cs
  • src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Recovery.cs
  • src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Sessions.cs
  • src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs
  • src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs
  • src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs
  • src/OpenClaw.Gateway/Endpoints/IntegrationEndpoints.cs
  • src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs
  • src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs
  • src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.cs
  • src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs
  • src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs
  • src/OpenClaw.Gateway/Extensions/TurnIdentity.cs
  • src/OpenClaw.Gateway/Mcp/OpenClawMcpTools.cs
  • src/OpenClaw.Tests/CompanionConnectionTests.cs
  • src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs
  • src/OpenClaw.Tests/GatewayAdminEndpointTests.cs
  • src/OpenClaw.Tests/GatewayWorkersTests.cs
  • src/OpenClaw.Tests/SessionOwnershipTests.cs
  • src/OpenClaw.Tests/TurnIdentityTests.cs
  • src/OpenClaw.Tests/WebSocketChannelTests.cs
  • src/OpenClaw.Tests/WebSocketEndpointsTests.cs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Telli Telli left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed session ownership across HTTP, WebSocket, browser-session authentication, and session-management routes. Three remaining paths bypass the intended owner-or-admin boundary; details are inline.

Comment thread src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs Outdated
Comment thread src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs Outdated
Comment thread src/OpenClaw.Core/Sessions/SessionAccess.cs
Telli and others added 4 commits September 29, 2026 03:58
…role

Companion's preflight refused to open chat for any gateway-reported role
below operator. With Security.AllowViewerAgentExecution on, the gateway
still admits those viewers, so the documented migration switch did not
work for Companion users.

GET /auth/session now reports canExecuteAgent, computed by the same rule
CanExecuteAgent enforces, including the opt-out. Companion blocks only
when the gateway says false; when the field is absent (older gateways)
it connects and lets the gateway decide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…p-authenticated-account

# Conflicts:
#	CHANGELOG.md
- /ws resolved its account with a helper that returned nothing on a
  loopback-bound gateway, even with AlwaysRequireAuth or OIDC. Those
  turns carried no account, so the owner check treated them as
  accountless and let them write anywhere, and new sessions had no
  owner. /ws now uses the same caller resolution as the other surfaces.
- /v1/* stable sessions are keyed by the bearer token's hash or, for
  browser sessions, the client address. Two signed-in accounts behind
  one address derived the same session and the second ran the first's
  conversation. Both endpoints now apply the owner check under the
  session lock and return 403 session_forbidden.
- Session management (delete, metadata, abort, branch restore, guided
  recovery) stayed operator-wide, so another operator could delete an
  owned session and recreate the id as its own. Those routes now follow
  the owner-or-admin rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Telli
Telli changed the base branch from fix/stamp-authenticated-account to main September 29, 2026 12:11
@Telli

Telli commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Reopening to run CI against main.

@Telli Telli closed this Sep 29, 2026
@Telli Telli reopened this Sep 29, 2026
@Telli
Telli merged commit 3b3cfc9 into main Sep 29, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants