Files
microfish/backend/app/services/identity.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
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.
2026-08-31 13:05:21 +07:00

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