Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).
Backend additions:
- accounts/me/security GET response gains active_users: list of
{user_id, name, email, last_login_at} for users in this account
with at least one un-revoked refresh token. Joined query on
refresh_tokens + users, distinct, ordered by last_login desc.
Drives the Active Sessions section.
Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
auth store, returns warning ('none'|'soon'|'now') + reason
('idle'|'absolute') so consumers can pick the right UX for the
closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
button hits authApi.refresh() and updates the store on success.
Absolute case: info-cyan tone, [Sign in now] link to /login (no
recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
the two bulk-revoke scopes. Title, body, and confirm-label vary by
scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
Header (Shield icon), intro, Policy card with Strict/Standard/Custom
radios + always-visible-disabled Custom inputs (idle/absolute
minutes) with inline validation, Save button + emerald success ping,
info note about 'applies at next login'. Active sessions card with
count-aware copy, list of {name, email, last-login-ago} rows
(caller tagged '(you)'), two buttons — 'except me' hidden when
count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.
Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).
Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
215 lines
8.0 KiB
Python
215 lines
8.0 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 (
|
|
ActiveUser,
|
|
RevokeSessionsRequest,
|
|
RevokeSessionsResponse,
|
|
SessionPolicyResponse,
|
|
SessionPolicyUpdateRequest,
|
|
)
|
|
|
|
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
|
|
|
|
|
|
def _policy_response(
|
|
account: Account, active_users: list[ActiveUser]
|
|
) -> 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,
|
|
active_users=active_users,
|
|
)
|
|
|
|
|
|
async def _load_account(db: AsyncSession, account_id) -> Account:
|
|
return (
|
|
await db.execute(select(Account).where(Account.id == account_id))
|
|
).scalar_one()
|
|
|
|
|
|
async def _load_active_users(db: AsyncSession, account_id) -> list[ActiveUser]:
|
|
"""Return distinct users in this account who currently hold an
|
|
un-revoked refresh token. See plan §4.7."""
|
|
from app.models.refresh_token import RefreshToken
|
|
|
|
stmt = (
|
|
select(User.id, User.name, User.email, User.last_login)
|
|
.join(RefreshToken, RefreshToken.user_id == User.id)
|
|
.where(User.account_id == account_id, RefreshToken.revoked_at.is_(None))
|
|
.distinct()
|
|
.order_by(User.last_login.desc().nulls_last())
|
|
)
|
|
rows = (await db.execute(stmt)).all()
|
|
return [
|
|
ActiveUser(user_id=row.id, name=row.name, email=row.email, last_login_at=row.last_login)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@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)
|
|
active_users = await _load_active_users(db, current_user.account_id)
|
|
return _policy_response(account, active_users)
|
|
|
|
|
|
@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)
|
|
active_users = await _load_active_users(db, account.id)
|
|
return _policy_response(account, active_users)
|
|
|
|
|
|
@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)
|