feat(auth): add session policy settings + account columns + migration
First commit in the session-expiration-policy series (see docs/plans/2026-05-13-session-expiration-policy.md). No behavior change yet — this lays the schema + settings groundwork only. - Settings: SESSION_IDLE_MINUTES_DEFAULT=4320 (3d), SESSION_ABSOLUTE_MINUTES_DEFAULT=20160 (14d), plus MIN/MAX bounds so account overrides have envelopes (15min..30d idle, 1h..90d absolute). - accounts table: nullable session_idle_minutes and session_absolute_minutes columns (NULL = use system default), plus a CHECK constraint that rejects idle > absolute when both are set. Partial-override validation lives at the app layer because the DB cannot read Settings. Subsequent commits will: distinguish idle vs invalid-token expiry on the wire, embed auth_time/idle_max/abs_max in refresh JWTs, enforce the absolute cap in /auth/refresh, add the owner-only policy + bulk-revoke endpoints, and surface everything in an AccountSecurity settings page with a session-expiry toast. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""add_session_policy_columns_to_accounts
|
||||
|
||||
Revision ID: b269a1add160
|
||||
Revises: 4ce3e594cb87
|
||||
Create Date: 2026-05-13 19:50:51.343777
|
||||
|
||||
Adds per-account session-policy overrides. NULL on either column means
|
||||
"use the system default from Settings.SESSION_*_MINUTES_DEFAULT." The
|
||||
CHECK constraint is defense-in-depth for the both-set case; the partial-
|
||||
override case (one NULL, one set) is validated at the app layer because
|
||||
the DB cannot see Settings.
|
||||
|
||||
See docs/plans/2026-05-13-session-expiration-policy.md for full design.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'b269a1add160'
|
||||
down_revision: Union[str, None] = '4ce3e594cb87'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'accounts',
|
||||
sa.Column(
|
||||
'session_idle_minutes',
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment=(
|
||||
'Account override for idle session window in minutes. '
|
||||
'NULL = use Settings.SESSION_IDLE_MINUTES_DEFAULT.'
|
||||
),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'accounts',
|
||||
sa.Column(
|
||||
'session_absolute_minutes',
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment=(
|
||||
'Account override for absolute session lifetime in minutes. '
|
||||
'NULL = use Settings.SESSION_ABSOLUTE_MINUTES_DEFAULT.'
|
||||
),
|
||||
),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'session_idle_le_absolute_when_both_set',
|
||||
'accounts',
|
||||
'('
|
||||
'session_idle_minutes IS NULL '
|
||||
'OR session_absolute_minutes IS NULL '
|
||||
'OR session_idle_minutes <= session_absolute_minutes'
|
||||
')',
|
||||
)
|
||||
op.execute(
|
||||
"COMMENT ON CONSTRAINT session_idle_le_absolute_when_both_set ON accounts IS "
|
||||
"'Defense in depth: catches idle > absolute when both are overridden. "
|
||||
"Partial-override case (one NULL, one set) is validated at the app layer "
|
||||
"against current system defaults, since the DB cannot see Settings.'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('session_idle_le_absolute_when_both_set', 'accounts', type_='check')
|
||||
op.drop_column('accounts', 'session_absolute_minutes')
|
||||
op.drop_column('accounts', 'session_idle_minutes')
|
||||
@@ -69,6 +69,19 @@ class Settings(BaseSettings):
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 5
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Session policy — see docs/plans/2026-05-13-session-expiration-policy.md
|
||||
# Refresh tokens enforce two windows: idle (between rotations) and absolute
|
||||
# (from original login). Defaults can be overridden per-account, bounded by
|
||||
# the MIN/MAX values below. Values are minutes everywhere except inside the
|
||||
# refresh JWT, where idle_max/abs_max are stored as seconds for direct
|
||||
# Unix-time math.
|
||||
SESSION_IDLE_MINUTES_DEFAULT: int = 4320 # 3 days
|
||||
SESSION_ABSOLUTE_MINUTES_DEFAULT: int = 20160 # 14 days
|
||||
SESSION_IDLE_MINUTES_MIN: int = 15
|
||||
SESSION_IDLE_MINUTES_MAX: int = 43200 # 30 days
|
||||
SESSION_ABSOLUTE_MINUTES_MIN: int = 60 # 1 hour
|
||||
SESSION_ABSOLUTE_MINUTES_MAX: int = 129600 # 90 days
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ class Account(Base):
|
||||
Integer, nullable=True, default=100, server_default="100"
|
||||
)
|
||||
|
||||
# Session policy override (NULL = use Settings.SESSION_*_MINUTES_DEFAULT).
|
||||
# Validated at the app layer because the DB cannot see Settings; a DB
|
||||
# CHECK constraint covers the both-set case only.
|
||||
session_idle_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
session_absolute_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Custom branding (Task 9)
|
||||
branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4
|
||||
|
||||
Reference in New Issue
Block a user