Fifth commit in the session-expiration-policy series. Surfaces the session-policy override controls to account owners. - schemas/account_security.py: NEW. SessionPolicyResponse returns both the override (Optional[int]) and the effective value (always present) plus the system min/max bounds, so the frontend can render the Custom-preset form without re-implementing the defaults logic. SessionPolicyUpdateRequest accepts NULL to clear an override. - endpoints/account_security.py: NEW. GET and PATCH on /me/security. Owner-only via require_account_owner. PATCH validates per-field bounds, then validates the effective idle <= absolute invariant (catching the partial-override case the DB CHECK can't see), then writes the row + an account.session_policy_update audit event with old/new/effective_old/effective_new payload. - router.py: registers the new router under _tenant_deps next to accounts.router. Tests added in test_session_policy.py (8 cases): - GET returns NULL overrides + Strict defaults + system bounds. - PATCH persists override; next login JWT reflects new values (60min/240min -> idle_max=3600, abs_max=14400 seconds). - PATCH rejects idle < min (422). - PATCH rejects absolute > max (422). - PATCH rejects idle > absolute when both are set (422). - PATCH rejects partial override that produces effective idle > effective absolute (idle=43200, absolute=NULL with default 20160). - Engineer-role user gets 403. - PATCH writes exactly one audit row with the expected payload shape. 16/16 in test_session_policy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
5.2 KiB
Python
139 lines
5.2 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 typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
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.user import User
|
|
from app.schemas.account_security import (
|
|
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)
|