M15: synchronize contracts and acceptance

This commit is contained in:
NuklearRabbit
2026-08-10 05:39:19 +02:00
parent 58fb515337
commit 2ee8b2d82b
38 changed files with 5091 additions and 1727 deletions
+3 -1
View File
@@ -13,6 +13,7 @@ from app.core.db import SessionLocal
from app.core.security import SessionPayload, read_session_token
from app.models.user import User
from app.schemas import CurrentUser, Role
from app.services.sessions import is_session_revoked
settings = get_settings()
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
@@ -29,7 +30,8 @@ def get_db() -> Generator[Session, None, None]:
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
token = request.cookies.get(settings.session_cookie_name)
payload: SessionPayload | None = read_session_token(token) if token else None
if payload is None or payload.role not in _VALID_ROLES:
revoked = token is not None and is_session_revoked(db, token)
if payload is None or payload.role not in _VALID_ROLES or revoked:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
# signed demo session until the reset endpoint clears its cookie keeps existing demo
+4 -1
View File
@@ -19,6 +19,7 @@ from app.core.security import (
from app.models.user import User
from app.schemas import CurrentUser, PasswordLoginRequest
from app.services.audit import record_audit_event
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
@@ -37,6 +38,7 @@ def _set_session(response: Response, user: User) -> None:
SessionPayload(
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
display_name=user.display_name, issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
)
)
response.set_cookie(
@@ -114,7 +116,8 @@ def get_session(response: Response, user: CurrentUser = Depends(get_current_user
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None
if payload is not None:
if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event(
db,
actor_type="user",
+4 -1
View File
@@ -15,6 +15,7 @@ from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
@@ -48,6 +49,7 @@ def demo_login(
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
)
)
response.set_cookie(
@@ -85,7 +87,8 @@ def get_session(
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None
if payload is not None:
if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event(
db,
actor_type="user",
+6
View File
@@ -20,6 +20,7 @@ class SessionPayload:
role: str
display_name: str
issued_at: int
session_id: str = ""
def _sign(data: bytes) -> str:
@@ -27,6 +28,11 @@ def _sign(data: bytes) -> str:
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def session_token_hash(token: str) -> str:
"""Return a non-reversible identifier safe to persist for token revocation."""
return hashlib.sha256(token.encode()).hexdigest()
def create_session_token(payload: SessionPayload) -> str:
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
+2
View File
@@ -7,6 +7,7 @@ from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.revoked_session import RevokedSession
from app.models.user import User
from app.models.vehicle import Vehicle
@@ -20,6 +21,7 @@ __all__ = [
"Inspection",
"MaintenanceRecord",
"OutboxEvent",
"RevokedSession",
"User",
"Vehicle",
]
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "revoked_sessions"
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
+2
View File
@@ -19,6 +19,7 @@ from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.models.revoked_session import RevokedSession
from app.models.user import User
from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event
@@ -92,6 +93,7 @@ _PERSISTENT_TELEMETRY_ACTIONS = (
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
for model in (
RevokedSession,
OutboxEvent,
IdempotencyRecord,
DataQualityIssue,
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import SessionPayload, session_token_hash
from app.models.revoked_session import RevokedSession
settings = get_settings()
def is_session_revoked(db: Session, token: str) -> bool:
token_hash = session_token_hash(token)
revoked_id = db.scalar(
select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)
)
return revoked_id is not None
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
token_hash = session_token_hash(token)
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
if existing is not None:
return
db.add(
RevokedSession(
token_hash=token_hash,
expires_at=datetime.fromtimestamp(
payload.issued_at + settings.session_ttl_seconds, UTC
),
)
)