fix(gateway): add caller authorization to Delete / Suspend / Resume session endpoints#561
Merged
Merged
Conversation
…ession endpoints (closes #558) Closes the per-session authorization gap on the three remaining state-mutating endpoints in SessionsController. Without these guards, any authenticated caller able to discover another caller's sessionId could: - Delete that session - Suspend an Active session, taking it off-line - Resume a Suspended session This mirrors the AuthorizeSessionCaller pattern already enforced on GetMetadata, PatchMetadata, and (as of PR #559) Seal. The pattern is consistent across the controller now. Behaviour: - Suspend / Resume: load session (existing 404 on missing) -> caller authorization (new) -> existing state-machine guard. - Delete: now loads the session before deleting so it can authorize. The idempotent NoContent semantic for missing sessions is preserved - surfacing 404 to authenticated probes would create an existence- disclosure oracle and break DELETE retry idempotency. Tests (9 new RED-then-GREEN, 3 per endpoint): - Delete_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotDelete - Delete_WhenCallerIdentityMatchesSessionCaller_DeletesAndReturnsNoContent - Delete_WhenSessionMissing_ReturnsNoContent_PreservingIdempotency - Suspend_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState - Suspend_WhenCallerIdentityMatchesSessionCaller_Suspends - Suspend_WithMissingCallerIdentity_SkipsAuthorizationCheck - Resume_WhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState - Resume_WhenCallerIdentityMatchesSessionCaller_Resumes - Resume_WithMissingCallerIdentity_SkipsAuthorizationCheck Build: 0 warnings / 0 errors under TreatWarningsAsErrors. Full suite: 0 failed (Gateway.Tests 1822 -> 1831 = +9). Closes #558 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes the per-session caller-authorization gap on three session lifecycle endpoints that mirrored the gap fixed on
Sealin #559:DELETE /api/sessions/{id}—DeletePOST /api/sessions/{id}/suspend—SuspendPOST /api/sessions/{id}/resume—ResumeBefore this PR, any authenticated caller could destroy, suspend, or resume any other caller's session. After this PR, the same
AuthorizeSessionCallerhelper (introduced in #555 / #559) is invoked on all three endpoints — placed between the existingis null → 404 / NoContentguard and any state-machine guard.Closes #558.Why
The original conversation behind the domain-model refactor flagged inconsistent authorization across session endpoints as a key source of "slop and instability." This PR finishes the symmetric pattern: every session-modifying endpoint (Seal / PatchMetadata / Delete / Suspend / Resume) now enforces caller-ownership identically.
Changes
Two files. +228 / -1 LoC.
src/gateway/BotNexus.Gateway.Api/Controllers/SessionsController.cs(+27 / -1)Delete: now loads the session before deleting. Missing → idempotentNoContent(existing wire semantic preserved). Existing →AuthorizeSessionCaller(session)→DeleteAsync. Comment block explains the idempotency-vs-existence-disclosure trade-off and references [Gateway] Add per-session caller authorization to Delete / Suspend / Resume endpoints (security gap) #558.Suspend:AuthorizeSessionCallercheck inserted between the existingis null → NotFoundguard and theStatus != Active → Conflictguard. Placement is deliberate — leaking "session is suspended" to a non-owner is also a small information disclosure.Resume: same shape asSuspend.tests/gateway/BotNexus.Gateway.Tests/SessionsControllerTests.cs(+201 / 0)10 new tests:
DeleteWhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotDelete,WhenCallerIdentityMatchesSessionCaller_DeletesAndReturnsNoContent,WhenSessionMissing_ReturnsNoContent_PreservingIdempotency,WithMissingCallerIdentity_SkipsAuthorizationCheckSuspendWhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState,WhenCallerIdentityMatchesSessionCaller_Suspends,WithMissingCallerIdentity_SkipsAuthorizationCheckResumeWhenCallerIdentityDoesNotMatchSessionCaller_Returns403_AndDoesNotChangeState,WhenCallerIdentityMatchesSessionCaller_Resumes,WithMissingCallerIdentity_SkipsAuthorizationCheckMismatched-caller tests assert both
StatusCode == 403and no state change (Delete:stillThere.ShouldNotBeNull; Suspend/Resume:session.Statusunchanged from seed).Validation
TreatWarningsAsErrors.SessionsControllerTests67/67 passed (was 57 — +10).BotNexus.Gateway.Tests1832 passed / 1 skipped / 0 failed (was 1822). 1 known flake inBotNexus.Cron.Tests(timing-sensitive) — passed on re-run.AgentKindArchitectureTests5/5 (allowlist still 3 entries from feat(gateway): migrate SessionsController.Seal off SessionId.IsSubAgent substring (Phase 5 / F-6 step 2b) #559).Critique sweep
Three independent models reviewed the change before this PR was opened. All findings either adopted, accepted as documented trade-off, or split out into follow-up.
authz-siblings-securityauthz-siblings-plan-vs-implDeletewas missing the standardWithMissingCallerIdentity_SkipsAuthorizationChecktest that all 5 sibling endpoints carry. Adopted — added in commit.authz-siblings-bug-huntOn the Delete 204/403 existence oracle (cross-reviewer disagreement)
The bug-hunt agent flagged this as BLOCKING; security agent flagged it as LOW; plan-vs-impl agent explicitly analyzed it and judged the design choice sound. I am rejecting the BLOCKING label and accepting the trade-off, with these reasons:
GET /api/sessions/{id}/metadatareturns404for missing and403for "exists but mismatched". The same existence channel is already accessible to any authenticated caller; closing only DELETE closes no information channel. (Source: plan-vs-impl agent's analysis.)mainalready returnedNoContentfor missing sessions.In-code comment block at
SessionsController.cs:274-278and test comment atSessionsControllerTests.cs:104-107document this trade-off explicitly so future maintainers see it.Other findings — adopted
Delete_WithMissingCallerIdentity_SkipsAuthorizationChecktest (commit-amended). Mirrors the same test on Suspend / Resume / Seal / GetMetadata / PatchMetadata.Other findings — split to follow-up
??=).SessionStoreExistenceQueryTests+SessionModelWave2Tests.Get/List/ListSubAgents/GetHistory/KillSubAgentalso lack caller authz): filed as [Gateway] Add per-session caller authorization to read endpoints (Get / List / ListSubAgents / GetHistory / KillSubAgent) #560 for follow-up PR.Mutation-resistance evidence
The new mismatched-caller tests catch the following mutations:
AuthorizeSessionCallercall entirelyDelete:stillThere.ShouldNotBeNullafter 403 attempt.Suspend/Resume:session.Statusunchanged after 403 attempt.if (... is not null)tois nullResumetest seedsStatus = Suspended; the test asserts 403 (not 409) — would catch reordering.is nullguardNoContent(not 403); would catch reordering.Related
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com