Caller must be authenticated and an org member. Authorisation is then:
Session owner (user_id matches): always allowed, regardless of role.
Rationale: a user can always close their own session. Documented
explicitly because the role-separation invariant otherwise rejects
OWNER/ADMIN without a grant.
Org OWNER: allowed without a separate role grant (per project
convention — OWNER is the unconditional administrative bypass for
cross-cutting lifecycle ops like force-terminate).
Caller holds ShellRole.SESSION_TERMINATE (“device.shell.terminate”):
allowed. This is the normal path for admins who have been granted
the terminate role.
Any other caller: 403.
Cross-org isolation: the session must belong to organization_id; a
session in a different org returns 404 (not 403) to avoid leaking
existence information — matches the convention established in approve/deny.
Status guard: only ACTIVE or PENDING sessions can be force-terminated.
An already-ENDED session returns 409 Conflict.
Behaviour
On success:
Row-locks the ShellSession row (serialised against concurrent mutations).
Appends a session.ended audit event in the same transaction.
Commits.
Calls gateway_client.kill_session() OUTSIDE the transaction (best-effort
WS close — if the gateway is down the backend has already flipped the row).
Errors
404 — session not found, or belongs to a different org.
409 — session is already ENDED or DENIED.
403 — caller lacks terminate permission.
204 — success.