Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
261 lines
9.2 KiB
Python
261 lines
9.2 KiB
Python
"""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
|