Files
resolutionflow/backend/app/api/endpoints/account_security.py
Michael Chihlas cabd745a2b feat(api): add POST /accounts/me/security/revoke-sessions
Sixth commit in the session-expiration-policy series. The kill-all-
sessions endpoint folded into scope after the §4.11 design pass.

- POST /accounts/me/security/revoke-sessions, owner-only.
- Body: {"scope": "all" | "others"}. Default "all" includes the caller's
  own refresh token. "others" preserves the caller's sessions so an
  owner can sign everyone else out without logging themselves out.
- Single SQL UPDATE through users.account_id -> refresh_tokens, with
  revoked_at IS NULL preserved as the gate so already-revoked rows
  don't get double-stamped (the idempotency property).
- Caller's access token is not touched — it dies on its 5-minute timer.
  Frontend handles "scope=all" UX by clearing localStorage and
  redirecting after the response (commit 8).
- Affected users' next /auth/refresh hits the existing atomic-revoke
  zero-rows path -> invalid_refresh_token (plain logout, no banner).
- Writes one account.sessions_revoked_bulk audit event with
  {scope, revoked_count}.

Tests added in test_session_policy.py (6 cases):
- #17 scope=all kills caller's own session; their refresh -> 401
  invalid_refresh_token.
- #18 scope=others preserves caller's session; their refresh succeeds,
  member's refresh -> 401 invalid_refresh_token.
- #19 account-scoped: test_admin in a different account is unaffected
  when test_user's owner runs revoke-all (revoked_count=1, not 2).
- #20 engineer-role member -> 403.
- #21 emits exactly one audit row with the expected payload.
- #22 idempotent: second immediate POST returns revoked_count=0.

22/22 in test_session_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:31:10 -04:00

190 lines
7.1 KiB
Python

"""Account session-policy endpoints — owner-only.
GET /accounts/me/security — read the policy + system bounds.
PATCH /accounts/me/security — set or clear the per-account override.
POST /accounts/me/security/revoke-sessions lands in the next commit.
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 / §4.11.
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_account_owner
from app.core.admin_database import get_admin_db
from app.core.audit import log_audit
from app.core.config import settings
from app.core.security import resolve_session_policy
from app.models.account import Account
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.schemas.account_security import (
RevokeSessionsRequest,
RevokeSessionsResponse,
SessionPolicyResponse,
SessionPolicyUpdateRequest,
)
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
def _policy_response(account: Account) -> SessionPolicyResponse:
eff_idle, eff_abs = resolve_session_policy(account)
return SessionPolicyResponse(
idle_minutes=account.session_idle_minutes,
absolute_minutes=account.session_absolute_minutes,
effective_idle_minutes=eff_idle,
effective_absolute_minutes=eff_abs,
idle_minutes_min=settings.SESSION_IDLE_MINUTES_MIN,
idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX,
absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN,
absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX,
)
async def _load_account(db: AsyncSession, account_id) -> Account:
return (
await db.execute(select(Account).where(Account.id == account_id))
).scalar_one()
@router.get("", response_model=SessionPolicyResponse)
async def get_session_policy(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
account = await _load_account(db, current_user.account_id)
return _policy_response(account)
@router.patch("", response_model=SessionPolicyResponse)
async def update_session_policy(
body: SessionPolicyUpdateRequest,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
account = await _load_account(db, current_user.account_id)
# Snapshot effective values BEFORE change, for audit.
old_idle = account.session_idle_minutes
old_abs = account.session_absolute_minutes
effective_old_idle, effective_old_abs = resolve_session_policy(account)
new_idle = body.idle_minutes
new_abs = body.absolute_minutes
# Per-field bound checks. NULL clears the override and is always valid.
if new_idle is not None and not (
settings.SESSION_IDLE_MINUTES_MIN <= new_idle <= settings.SESSION_IDLE_MINUTES_MAX
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"idle_minutes must be between {settings.SESSION_IDLE_MINUTES_MIN} "
f"and {settings.SESSION_IDLE_MINUTES_MAX}"
),
)
if new_abs is not None and not (
settings.SESSION_ABSOLUTE_MINUTES_MIN <= new_abs <= settings.SESSION_ABSOLUTE_MINUTES_MAX
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"absolute_minutes must be between {settings.SESSION_ABSOLUTE_MINUTES_MIN} "
f"and {settings.SESSION_ABSOLUTE_MINUTES_MAX}"
),
)
# Effective-value invariant: idle must not exceed absolute after defaults.
# The DB CHECK only catches the both-set case; this catches the partial-
# override case where (e.g.) idle=43200 with absolute=NULL would yield an
# effective idle larger than the system default absolute.
effective_new_idle = new_idle if new_idle is not None else settings.SESSION_IDLE_MINUTES_DEFAULT
effective_new_abs = new_abs if new_abs is not None else settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
if effective_new_idle > effective_new_abs:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Effective idle ({effective_new_idle}min) cannot exceed effective "
f"absolute ({effective_new_abs}min)"
),
)
account.session_idle_minutes = new_idle
account.session_absolute_minutes = new_abs
await log_audit(
db,
user_id=current_user.id,
account_id=account.id,
action="account.session_policy_update",
resource_type="account",
resource_id=account.id,
details={
"old": {"idle_minutes": old_idle, "absolute_minutes": old_abs},
"new": {"idle_minutes": new_idle, "absolute_minutes": new_abs},
"effective_old": {
"idle_minutes": effective_old_idle,
"absolute_minutes": effective_old_abs,
},
"effective_new": {
"idle_minutes": effective_new_idle,
"absolute_minutes": effective_new_abs,
},
},
)
await db.commit()
await db.refresh(account)
return _policy_response(account)
@router.post("/revoke-sessions", response_model=RevokeSessionsResponse)
async def revoke_sessions(
body: RevokeSessionsRequest,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
"""Bulk-revoke refresh tokens for users in the caller's account.
`scope="all"` revokes every active session in the account, including
the caller's own. `scope="others"` preserves the caller's sessions.
The caller's access token is NOT revoked (we don't track access JTIs);
it dies on its 5-minute timer. For `scope="all"`, the frontend is
expected to log the caller out locally after the response.
See docs/plans/2026-05-13-session-expiration-policy.md §4.11.
"""
# Subquery: refresh-token rows belonging to users in this account.
user_ids_subq = select(User.id).where(User.account_id == current_user.account_id)
stmt = (
sa_update(RefreshToken)
.where(
RefreshToken.user_id.in_(user_ids_subq),
RefreshToken.revoked_at.is_(None),
)
.values(revoked_at=datetime.now(timezone.utc))
.returning(RefreshToken.id)
)
if body.scope == "others":
stmt = stmt.where(RefreshToken.user_id != current_user.id)
result = await db.execute(stmt)
revoked_count = len(result.all())
await log_audit(
db,
user_id=current_user.id,
account_id=current_user.account_id,
action="account.sessions_revoked_bulk",
resource_type="account",
resource_id=current_user.account_id,
details={"scope": body.scope, "revoked_count": revoked_count},
)
await db.commit()
return RevokeSessionsResponse(revoked_count=revoked_count)