"""Tenant-scoped identity repository and password service.""" from __future__ import annotations import hashlib import secrets from dataclasses import dataclass from datetime import datetime, timedelta, timezone from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError from sqlalchemy import select from sqlalchemy.orm import Session from ..models.saas import AuthSession, Membership, Organization, User from ..security.policy import Role class PasswordService: """Argon2id password hashing with no plaintext fallback.""" _hasher = PasswordHasher() @classmethod def hash_password(cls, password: str) -> str: if not isinstance(password, str) or len(password) < 12: raise ValueError("password_too_short") return cls._hasher.hash(password) @classmethod def verify_password(cls, password_hash: str, password: str) -> bool: if not isinstance(password_hash, str) or not password_hash: return False try: return cls._hasher.verify(password_hash, password) except (VerifyMismatchError, VerificationError, InvalidHashError): return False class IdentityRepository: """Flush-only repository; the caller owns transaction boundaries.""" def __init__(self, session: Session): self.session = session @staticmethod def normalize_email(email: str) -> str: if not isinstance(email, str): raise ValueError("invalid_email") normalized = email.strip().casefold() if "@" not in normalized or normalized.startswith("@") or normalized.endswith("@"): raise ValueError("invalid_email") return normalized @staticmethod def normalize_slug(slug: str) -> str: if not isinstance(slug, str): raise ValueError("invalid_slug") normalized = slug.strip().casefold() if not normalized or any(char not in "abcdefghijklmnopqrstuvwxyz0123456789-" for char in normalized): raise ValueError("invalid_slug") return normalized def create_organization(self, *, name: str, slug: str) -> Organization: if not isinstance(name, str) or not name.strip(): raise ValueError("invalid_organization_name") organization = Organization(name=name.strip(), slug=self.normalize_slug(slug)) self.session.add(organization) self.session.flush() return organization def create_user(self, *, email: str, password_hash: str | None = None) -> User: user = User( email_normalized=self.normalize_email(email), password_hash=password_hash or "!invite_pending", ) self.session.add(user) self.session.flush() return user def create_membership(self, user_id: str, organization_id: str, role: Role | str) -> Membership: try: normalized_role = role if isinstance(role, Role) else Role(role) except (TypeError, ValueError) as exc: raise ValueError("invalid_role") from exc membership = Membership( user_id=user_id, organization_id=organization_id, role=normalized_role, ) self.session.add(membership) self.session.flush() return membership def get_organization(self, organization_id: str) -> Organization | None: return self.session.scalar( select(Organization).where(Organization.id == organization_id) ) def get_organization_by_slug(self, slug: str) -> Organization | None: normalized = self.normalize_slug(slug) return self.session.scalar( select(Organization).where(Organization.slug == normalized) ) def list_active_memberships(self, user_id: str) -> list[tuple[Membership, Organization]]: return list( self.session.execute( select(Membership, Organization) .join(Organization, Organization.id == Membership.organization_id) .where( Membership.user_id == user_id, Membership.status == "active", Organization.status == "active", ) .order_by(Organization.slug) ).all() ) def get_user_by_email(self, email: str) -> User | None: normalized = self.normalize_email(email) return self.session.scalar( select(User).where(User.email_normalized == normalized) ) def get_user_for_org(self, user_id: str, organization_id: str) -> User | None: """Tenant-scoped read; wrong-org IDs return None without disclosure.""" return self.session.scalar( select(User) .join(Membership, Membership.user_id == User.id) .where( User.id == user_id, Membership.organization_id == organization_id, Membership.status == "active", User.status == "active", ) ) def list_users_with_memberships(self, organization_id: str) -> list[tuple[User, Membership]]: return list( self.session.execute( select(User, Membership) .join(Membership, Membership.user_id == User.id) .where( Membership.organization_id == organization_id, Membership.status == "active", User.status == "active", ) .order_by(User.email_normalized) ).all() ) def list_users(self, organization_id: str) -> list[User]: """Return only active users with active membership in the tenant.""" return list( self.session.scalars( select(User) .join(Membership, Membership.user_id == User.id) .where( Membership.organization_id == organization_id, Membership.status == "active", User.status == "active", ) .order_by(User.email_normalized) ) ) @dataclass(frozen=True) class SessionContext: user: User membership: Membership organization: Organization class SessionService: """Opaque, revocable session token operations; caller owns commits.""" DEFAULT_TTL_SECONDS = 60 * 60 * 12 @staticmethod def _hash_token(raw_token: str) -> str: return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() @classmethod def create( cls, session: Session, user: User, membership_id: str, ttl_seconds: int | None = None, ) -> tuple[str, AuthSession]: if not isinstance(membership_id, str) or not membership_id: raise ValueError("invalid_membership") ttl = ttl_seconds or cls.DEFAULT_TTL_SECONDS if ttl < 60: raise ValueError("invalid_session_ttl") membership = session.scalar( select(Membership).where( Membership.id == membership_id, Membership.user_id == user.id, Membership.status == "active", ) ) if membership is None: raise ValueError("invalid_membership") raw_token = secrets.token_urlsafe(32) stored = AuthSession( user_id=user.id, membership_id=membership_id, token_hash=cls._hash_token(raw_token), auth_version=user.auth_version, expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl), ) session.add(stored) session.flush() return raw_token, stored @classmethod def resolve(cls, session: Session, raw_token: str) -> SessionContext | None: if not isinstance(raw_token, str) or not raw_token: return None now = datetime.now(timezone.utc) row = session.execute( select(AuthSession, User, Membership, Organization) .join(User, User.id == AuthSession.user_id) .join(Membership, Membership.id == AuthSession.membership_id) .join(Organization, Organization.id == Membership.organization_id) .where( AuthSession.token_hash == cls._hash_token(raw_token), AuthSession.revoked_at.is_(None), AuthSession.expires_at > now, AuthSession.auth_version == User.auth_version, User.status == "active", Membership.status == "active", Organization.status == "active", ) ).first() if row is None: return None stored, user, membership, organization = row stored.last_seen_at = now return SessionContext(user=user, membership=membership, organization=organization) @classmethod def revoke(cls, session: Session, raw_token: str) -> bool: if not isinstance(raw_token, str) or not raw_token: return False stored = session.scalar( select(AuthSession).where(AuthSession.token_hash == cls._hash_token(raw_token)) ) if stored is None or stored.revoked_at is not None: return False stored.revoked_at = datetime.now(timezone.utc) session.flush() return True