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>
57 lines
1.8 KiB
Python
57 lines
1.8 KiB
Python
"""Schemas for /accounts/me/security — session-policy management.
|
|
|
|
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11.
|
|
"""
|
|
from typing import Literal, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class SessionPolicyResponse(BaseModel):
|
|
"""GET /accounts/me/security — the policy in effect for this account.
|
|
|
|
Surfaces both the override (which may be NULL) and the effective value
|
|
(after defaults applied) so the frontend can show the current state
|
|
without re-implementing the defaults logic.
|
|
"""
|
|
|
|
# Per-account override values, NULL = "use system default."
|
|
idle_minutes: Optional[int] = Field(
|
|
default=None,
|
|
description="Account override; NULL means use the system default.",
|
|
)
|
|
absolute_minutes: Optional[int] = Field(default=None)
|
|
|
|
# Effective values after defaults applied (always non-NULL).
|
|
effective_idle_minutes: int
|
|
effective_absolute_minutes: int
|
|
|
|
# System-imposed bounds for the Custom-preset form inputs.
|
|
idle_minutes_min: int
|
|
idle_minutes_max: int
|
|
absolute_minutes_min: int
|
|
absolute_minutes_max: int
|
|
|
|
|
|
class SessionPolicyUpdateRequest(BaseModel):
|
|
"""PATCH /accounts/me/security — set or clear the per-account override.
|
|
|
|
Pass `null` for either field to clear the override and fall back to the
|
|
system default. Both bounds checks and the idle <= absolute invariant
|
|
are validated against the *effective* values at the endpoint, since the
|
|
DB CHECK constraint only covers the both-set case.
|
|
"""
|
|
|
|
idle_minutes: Optional[int] = None
|
|
absolute_minutes: Optional[int] = None
|
|
|
|
|
|
class RevokeSessionsRequest(BaseModel):
|
|
"""POST /accounts/me/security/revoke-sessions — bulk-revoke refresh tokens."""
|
|
|
|
scope: Literal["all", "others"] = "all"
|
|
|
|
|
|
class RevokeSessionsResponse(BaseModel):
|
|
revoked_count: int
|