From 4782fad5b9610b28664ce59561f91f452e78b48a Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 15:13:55 +0100 Subject: [PATCH 01/10] feat(auth): validate Authelia tokens in FastAPI --- backend/innercontext/api/auth.py | 166 +++++++++++ backend/innercontext/api/auth_deps.py | 57 ++++ backend/innercontext/auth.py | 384 ++++++++++++++++++++++++++ backend/main.py | 56 +++- backend/pyproject.toml | 1 + backend/tests/conftest.py | 22 ++ backend/tests/test_auth.py | 275 ++++++++++++++++++ 7 files changed, 953 insertions(+), 8 deletions(-) create mode 100644 backend/innercontext/api/auth.py create mode 100644 backend/innercontext/api/auth_deps.py create mode 100644 backend/innercontext/auth.py create mode 100644 backend/tests/test_auth.py diff --git a/backend/innercontext/api/auth.py b/backend/innercontext/api/auth.py new file mode 100644 index 0000000..877289e --- /dev/null +++ b/backend/innercontext/api/auth.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Field, Session, SQLModel, select + +from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import CurrentUser, IdentityData, sync_current_user +from innercontext.models import HouseholdRole, Role, UserProfile + +router = APIRouter() + + +class SessionSyncRequest(SQLModel): + iss: str | None = None + sub: str | None = None + email: str | None = None + name: str | None = None + preferred_username: str | None = None + groups: list[str] | None = None + + +class AuthHouseholdMembershipPublic(SQLModel): + household_id: UUID + role: HouseholdRole + + +class AuthUserPublic(SQLModel): + id: UUID + role: Role + household_membership: AuthHouseholdMembershipPublic | None = None + + +class AuthIdentityPublic(SQLModel): + issuer: str + subject: str + email: str | None = None + name: str | None = None + preferred_username: str | None = None + groups: list[str] = Field(default_factory=list) + + +class AuthProfilePublic(SQLModel): + id: UUID + user_id: UUID | None + birth_date: date | None = None + sex_at_birth: str | None = None + created_at: datetime + updated_at: datetime + + +class AuthSessionResponse(SQLModel): + user: AuthUserPublic + identity: AuthIdentityPublic + profile: AuthProfilePublic | None = None + + +def _build_identity( + current_user: CurrentUser, + payload: SessionSyncRequest | None, +) -> IdentityData: + if payload is None: + return current_user.identity + + if payload.iss is not None and payload.iss != current_user.identity.issuer: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Session sync issuer does not match bearer token", + ) + if payload.sub is not None and payload.sub != current_user.identity.subject: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Session sync subject does not match bearer token", + ) + + return IdentityData( + issuer=current_user.identity.issuer, + subject=current_user.identity.subject, + email=( + payload.email if payload.email is not None else current_user.identity.email + ), + name=payload.name if payload.name is not None else current_user.identity.name, + preferred_username=( + payload.preferred_username + if payload.preferred_username is not None + else current_user.identity.preferred_username + ), + groups=( + tuple(payload.groups) + if payload.groups is not None + else current_user.identity.groups + ), + ) + + +def _get_profile(session: Session, user_id: UUID) -> UserProfile | None: + return session.exec( + select(UserProfile).where(UserProfile.user_id == user_id) + ).first() + + +def _profile_public(profile: UserProfile | None) -> AuthProfilePublic | None: + if profile is None: + return None + + return AuthProfilePublic( + id=profile.id, + user_id=profile.user_id, + birth_date=profile.birth_date, + sex_at_birth=( + profile.sex_at_birth.value if profile.sex_at_birth is not None else None + ), + created_at=profile.created_at, + updated_at=profile.updated_at, + ) + + +def _response(session: Session, current_user: CurrentUser) -> AuthSessionResponse: + household_membership = None + if current_user.household_membership is not None: + household_membership = AuthHouseholdMembershipPublic( + household_id=current_user.household_membership.household_id, + role=current_user.household_membership.role, + ) + + return AuthSessionResponse( + user=AuthUserPublic( + id=current_user.user_id, + role=current_user.role, + household_membership=household_membership, + ), + identity=AuthIdentityPublic( + issuer=current_user.identity.issuer, + subject=current_user.identity.subject, + email=current_user.identity.email, + name=current_user.identity.name, + preferred_username=current_user.identity.preferred_username, + groups=list(current_user.identity.groups), + ), + profile=_profile_public(_get_profile(session, current_user.user_id)), + ) + + +@router.post("/session/sync", response_model=AuthSessionResponse) +def sync_session( + payload: SessionSyncRequest | None = None, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + synced_user = sync_current_user( + session, + current_user.claims, + identity=_build_identity(current_user, payload), + ) + return _response(session, synced_user) + + +@router.get("/me", response_model=AuthSessionResponse) +def get_me( + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + return _response(session, current_user) diff --git a/backend/innercontext/api/auth_deps.py b/backend/innercontext/api/auth_deps.py new file mode 100644 index 0000000..a71a57a --- /dev/null +++ b/backend/innercontext/api/auth_deps.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlmodel import Session + +from db import get_session +from innercontext.auth import ( + AuthConfigurationError, + CurrentUser, + TokenValidationError, + sync_current_user, + validate_access_token, +) +from innercontext.models import Role + +_bearer_scheme = HTTPBearer(auto_error=False) + + +def _unauthorized(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_current_user( + credentials: Annotated[ + HTTPAuthorizationCredentials | None, Depends(_bearer_scheme) + ], + session: Session = Depends(get_session), +) -> CurrentUser: + if credentials is None or credentials.scheme.lower() != "bearer": + raise _unauthorized("Missing bearer token") + + try: + claims = validate_access_token(credentials.credentials) + return sync_current_user(session, claims) + except AuthConfigurationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except TokenValidationError as exc: + raise _unauthorized(str(exc)) from exc + + +def require_admin(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if current_user.role is not Role.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required", + ) + return current_user diff --git a/backend/innercontext/auth.py b/backend/innercontext/auth.py new file mode 100644 index 0000000..b672d43 --- /dev/null +++ b/backend/innercontext/auth.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import os +import time +from dataclasses import dataclass, field +from datetime import UTC, datetime +from functools import lru_cache +from threading import Lock +from typing import Any, Mapping +from uuid import UUID + +import httpx +import jwt +from jwt import InvalidTokenError, PyJWKSet +from sqlmodel import Session, select + +from innercontext.models import HouseholdMembership, HouseholdRole, Role, User + +_DISCOVERY_PATH = "/.well-known/openid-configuration" +_SUPPORTED_ALGORITHMS = frozenset( + {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"} +) + + +class AuthConfigurationError(RuntimeError): + pass + + +class TokenValidationError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class AuthSettings: + issuer: str + client_id: str + audiences: tuple[str, ...] + discovery_url: str + jwks_url: str | None + groups_claim: str + admin_groups: tuple[str, ...] + member_groups: tuple[str, ...] + jwks_cache_ttl_seconds: int + http_timeout_seconds: float + clock_skew_seconds: int + + +@dataclass(frozen=True, slots=True) +class TokenClaims: + issuer: str + subject: str + audience: tuple[str, ...] + expires_at: datetime + groups: tuple[str, ...] = () + email: str | None = None + name: str | None = None + preferred_username: str | None = None + raw_claims: Mapping[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_payload( + cls, payload: Mapping[str, Any], settings: AuthSettings + ) -> "TokenClaims": + audience = payload.get("aud") + if isinstance(audience, str): + audiences = (audience,) + elif isinstance(audience, list): + audiences = tuple(str(item) for item in audience) + else: + audiences = () + + groups = _normalize_groups(payload.get(settings.groups_claim)) + exp = payload.get("exp") + if not isinstance(exp, (int, float)): + raise TokenValidationError("Access token missing exp claim") + + return cls( + issuer=str(payload["iss"]), + subject=str(payload["sub"]), + audience=audiences, + expires_at=datetime.fromtimestamp(exp, tz=UTC), + groups=groups, + email=_optional_str(payload.get("email")), + name=_optional_str(payload.get("name")), + preferred_username=_optional_str(payload.get("preferred_username")), + raw_claims=dict(payload), + ) + + +@dataclass(frozen=True, slots=True) +class IdentityData: + issuer: str + subject: str + email: str | None = None + name: str | None = None + preferred_username: str | None = None + groups: tuple[str, ...] = () + + @classmethod + def from_claims(cls, claims: TokenClaims) -> "IdentityData": + return cls( + issuer=claims.issuer, + subject=claims.subject, + email=claims.email, + name=claims.name, + preferred_username=claims.preferred_username, + groups=claims.groups, + ) + + +@dataclass(frozen=True, slots=True) +class CurrentHouseholdMembership: + household_id: UUID + role: HouseholdRole + + +@dataclass(frozen=True, slots=True) +class CurrentUser: + user_id: UUID + role: Role + identity: IdentityData + claims: TokenClaims = field(repr=False) + household_membership: CurrentHouseholdMembership | None = None + + +def _split_csv(value: str | None) -> tuple[str, ...]: + if value is None: + return () + return tuple(item.strip() for item in value.split(",") if item.strip()) + + +def _optional_str(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return str(value) + + +def _normalize_groups(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + return (value,) + if isinstance(value, list): + return tuple(str(item) for item in value) + if isinstance(value, tuple): + return tuple(str(item) for item in value) + return (str(value),) + + +def _required_env(name: str) -> str: + value = os.environ.get(name) + if value: + return value + raise AuthConfigurationError(f"Missing required auth environment variable: {name}") + + +@lru_cache +def get_auth_settings() -> AuthSettings: + issuer = _required_env("OIDC_ISSUER") + client_id = _required_env("OIDC_CLIENT_ID") + audiences = _split_csv(os.environ.get("OIDC_AUDIENCE")) or (client_id,) + discovery_url = os.environ.get("OIDC_DISCOVERY_URL") or ( + issuer.rstrip("/") + _DISCOVERY_PATH + ) + + return AuthSettings( + issuer=issuer, + client_id=client_id, + audiences=audiences, + discovery_url=discovery_url, + jwks_url=os.environ.get("OIDC_JWKS_URL"), + groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"), + admin_groups=_split_csv(os.environ.get("OIDC_ADMIN_GROUPS")), + member_groups=_split_csv(os.environ.get("OIDC_MEMBER_GROUPS")), + jwks_cache_ttl_seconds=int( + os.environ.get("OIDC_JWKS_CACHE_TTL_SECONDS", "300") + ), + http_timeout_seconds=float(os.environ.get("OIDC_HTTP_TIMEOUT_SECONDS", "5")), + clock_skew_seconds=int(os.environ.get("OIDC_CLOCK_SKEW_SECONDS", "30")), + ) + + +class CachedJwksClient: + def __init__(self, settings: AuthSettings): + self._settings = settings + self._lock = Lock() + self._jwks: PyJWKSet | None = None + self._jwks_fetched_at = 0.0 + self._discovery_jwks_url: str | None = None + self._discovery_fetched_at = 0.0 + + def get_signing_key(self, kid: str) -> Any: + with self._lock: + jwks = self._get_jwks_locked() + key = self._find_key(jwks, kid) + if key is not None: + return key + + self._refresh_jwks_locked( + force_discovery_refresh=self._settings.jwks_url is None + ) + if self._jwks is None: + raise TokenValidationError("JWKS cache is empty") + + key = self._find_key(self._jwks, kid) + if key is None: + raise TokenValidationError(f"No signing key found for kid '{kid}'") + return key + + def _get_jwks_locked(self) -> PyJWKSet: + if self._jwks is None or self._is_stale(self._jwks_fetched_at): + self._refresh_jwks_locked(force_discovery_refresh=False) + if self._jwks is None: + raise TokenValidationError("Unable to load JWKS") + return self._jwks + + def _refresh_jwks_locked(self, force_discovery_refresh: bool) -> None: + jwks_url = self._resolve_jwks_url_locked(force_refresh=force_discovery_refresh) + data = self._fetch_json(jwks_url) + try: + self._jwks = PyJWKSet.from_dict(data) + except Exception as exc: + raise TokenValidationError( + "OIDC provider returned an invalid JWKS payload" + ) from exc + self._jwks_fetched_at = time.monotonic() + + def _resolve_jwks_url_locked(self, force_refresh: bool) -> str: + if self._settings.jwks_url: + return self._settings.jwks_url + + if ( + force_refresh + or self._discovery_jwks_url is None + or self._is_stale(self._discovery_fetched_at) + ): + discovery = self._fetch_json(self._settings.discovery_url) + jwks_uri = discovery.get("jwks_uri") + if not isinstance(jwks_uri, str) or not jwks_uri: + raise TokenValidationError("OIDC discovery document missing jwks_uri") + self._discovery_jwks_url = jwks_uri + self._discovery_fetched_at = time.monotonic() + + if self._discovery_jwks_url is None: + raise TokenValidationError("Unable to resolve JWKS URL") + return self._discovery_jwks_url + + def _fetch_json(self, url: str) -> dict[str, Any]: + try: + response = httpx.get(url, timeout=self._settings.http_timeout_seconds) + response.raise_for_status() + except httpx.HTTPError as exc: + raise TokenValidationError( + f"Failed to fetch OIDC metadata from {url}" + ) from exc + + data = response.json() + if not isinstance(data, dict): + raise TokenValidationError( + f"OIDC metadata from {url} must be a JSON object" + ) + return data + + def _is_stale(self, fetched_at: float) -> bool: + return (time.monotonic() - fetched_at) >= self._settings.jwks_cache_ttl_seconds + + @staticmethod + def _find_key(jwks: PyJWKSet, kid: str) -> Any | None: + for jwk in jwks.keys: + if jwk.key_id == kid: + return jwk.key + return None + + +@lru_cache +def get_jwks_client() -> CachedJwksClient: + return CachedJwksClient(get_auth_settings()) + + +def reset_auth_caches() -> None: + get_auth_settings.cache_clear() + get_jwks_client.cache_clear() + + +def validate_access_token(token: str) -> TokenClaims: + settings = get_auth_settings() + + try: + unverified_header = jwt.get_unverified_header(token) + except InvalidTokenError as exc: + raise TokenValidationError("Malformed access token header") from exc + + kid = unverified_header.get("kid") + algorithm = unverified_header.get("alg") + if not isinstance(kid, str) or not kid: + raise TokenValidationError("Access token missing kid header") + if not isinstance(algorithm, str) or algorithm not in _SUPPORTED_ALGORITHMS: + raise TokenValidationError("Access token uses an unsupported signing algorithm") + + signing_key = get_jwks_client().get_signing_key(kid) + + try: + payload = jwt.decode( + token, + key=signing_key, + algorithms=[algorithm], + audience=settings.audiences, + issuer=settings.issuer, + options={"require": ["exp", "iss", "sub"]}, + leeway=settings.clock_skew_seconds, + ) + except InvalidTokenError as exc: + raise TokenValidationError("Invalid access token") from exc + + return TokenClaims.from_payload(payload, settings) + + +def sync_current_user( + session: Session, + claims: TokenClaims, + identity: IdentityData | None = None, +) -> CurrentUser: + effective_identity = identity or IdentityData.from_claims(claims) + statement = select(User).where( + User.oidc_issuer == effective_identity.issuer, + User.oidc_subject == effective_identity.subject, + ) + user = session.exec(statement).first() + existing_role = user.role if user is not None else None + resolved_role = resolve_role(effective_identity.groups, existing_role=existing_role) + needs_commit = False + + if user is None: + user = User( + oidc_issuer=effective_identity.issuer, + oidc_subject=effective_identity.subject, + role=resolved_role, + ) + session.add(user) + needs_commit = True + elif user.role != resolved_role: + user.role = resolved_role + session.add(user) + needs_commit = True + + if needs_commit: + session.commit() + session.refresh(user) + + membership = session.exec( + select(HouseholdMembership).where(HouseholdMembership.user_id == user.id) + ).first() + + household_membership = None + if membership is not None: + household_membership = CurrentHouseholdMembership( + household_id=membership.household_id, + role=membership.role, + ) + + return CurrentUser( + user_id=user.id, + role=user.role, + identity=effective_identity, + claims=claims, + household_membership=household_membership, + ) + + +def resolve_role(groups: tuple[str, ...], existing_role: Role | None = None) -> Role: + settings = get_auth_settings() + if groups: + group_set = set(groups) + if settings.admin_groups and group_set.intersection(settings.admin_groups): + return Role.ADMIN + if settings.member_groups: + if group_set.intersection(settings.member_groups): + return Role.MEMBER + return Role.MEMBER + return Role.MEMBER + + return existing_role or Role.MEMBER diff --git a/backend/main.py b/backend/main.py index 10fb73b..55280aa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,13 +5,14 @@ from dotenv import load_dotenv load_dotenv() # load .env before db.py reads DATABASE_URL -from fastapi import FastAPI # noqa: E402 +from fastapi import Depends, FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 from sqlmodel import Session # noqa: E402 from db import create_db_and_tables, engine # noqa: E402 from innercontext.api import ( # noqa: E402 ai_logs, + auth, health, inventory, products, @@ -19,6 +20,7 @@ from innercontext.api import ( # noqa: E402 routines, skincare, ) +from innercontext.api.auth_deps import get_current_user # noqa: E402 from innercontext.services.pricing_jobs import enqueue_pricing_recalc # noqa: E402 @@ -47,13 +49,51 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router(products.router, prefix="/products", tags=["products"]) -app.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) -app.include_router(profile.router, prefix="/profile", tags=["profile"]) -app.include_router(health.router, prefix="/health", tags=["health"]) -app.include_router(routines.router, prefix="/routines", tags=["routines"]) -app.include_router(skincare.router, prefix="/skincare", tags=["skincare"]) -app.include_router(ai_logs.router, prefix="/ai-logs", tags=["ai-logs"]) +protected = [Depends(get_current_user)] + +app.include_router(auth.router, prefix="/auth", tags=["auth"]) +app.include_router( + products.router, + prefix="/products", + tags=["products"], + dependencies=protected, +) +app.include_router( + inventory.router, + prefix="/inventory", + tags=["inventory"], + dependencies=protected, +) +app.include_router( + profile.router, + prefix="/profile", + tags=["profile"], + dependencies=protected, +) +app.include_router( + health.router, + prefix="/health", + tags=["health"], + dependencies=protected, +) +app.include_router( + routines.router, + prefix="/routines", + tags=["routines"], + dependencies=protected, +) +app.include_router( + skincare.router, + prefix="/skincare", + tags=["skincare"], + dependencies=protected, +) +app.include_router( + ai_logs.router, + prefix="/ai-logs", + tags=["ai-logs"], + dependencies=protected, +) @app.get("/health-check") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index eeddb55..6b9a55c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "alembic>=1.14", "fastapi>=0.132.0", "google-genai>=1.65.0", + "pyjwt[crypto]>=2.10.1", "psycopg[binary]>=3.3.3", "python-dotenv>=1.2.1", "python-multipart>=0.0.22", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3c4f465..e35dfba 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,6 @@ import os +from datetime import UTC, datetime, timedelta +from uuid import uuid4 # Must be set before importing db (which calls create_engine at module level) os.environ.setdefault("DATABASE_URL", "sqlite://") @@ -10,6 +12,9 @@ from sqlmodel.pool import StaticPool import db as db_module from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import CurrentUser, IdentityData, TokenClaims +from innercontext.models import Role from main import app @@ -38,7 +43,24 @@ def client(session, monkeypatch): def _override(): yield session + def _current_user_override(): + claims = TokenClaims( + issuer="https://auth.test", + subject="test-user", + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + groups=("innercontext-admin",), + raw_claims={"iss": "https://auth.test", "sub": "test-user"}, + ) + return CurrentUser( + user_id=uuid4(), + role=Role.ADMIN, + identity=IdentityData.from_claims(claims), + claims=claims, + ) + app.dependency_overrides[get_session] = _override + app.dependency_overrides[get_current_user] = _current_user_override with TestClient(app) as c: yield c app.dependency_overrides.clear() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..0ed16a5 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import HTTPException +from fastapi.testclient import TestClient +from jwt import algorithms +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool + +import db as db_module +from db import get_session +from innercontext.api.auth_deps import require_admin +from innercontext.auth import ( + CurrentHouseholdMembership, + CurrentUser, + IdentityData, + TokenClaims, + reset_auth_caches, + validate_access_token, +) +from innercontext.models import ( + Household, + HouseholdMembership, + HouseholdRole, + Role, + User, +) +from main import app + + +class _MockResponse: + def __init__(self, payload: dict[str, object], status_code: int = 200): + self._payload = payload + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"unexpected status {self.status_code}") + + def json(self) -> dict[str, object]: + return self._payload + + +@pytest.fixture() +def auth_env(monkeypatch): + monkeypatch.setenv("OIDC_ISSUER", "https://auth.example.test") + monkeypatch.setenv("OIDC_CLIENT_ID", "innercontext-web") + monkeypatch.setenv( + "OIDC_DISCOVERY_URL", + "https://auth.example.test/.well-known/openid-configuration", + ) + monkeypatch.setenv("OIDC_ADMIN_GROUPS", "innercontext-admin") + monkeypatch.setenv("OIDC_MEMBER_GROUPS", "innercontext-member") + monkeypatch.setenv("OIDC_JWKS_CACHE_TTL_SECONDS", "3600") + reset_auth_caches() + yield + reset_auth_caches() + + +@pytest.fixture() +def rsa_keypair(): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + return private_key, private_key.public_key() + + +@pytest.fixture() +def auth_session(monkeypatch): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + monkeypatch.setattr(db_module, "engine", engine) + import innercontext.models # noqa: F401 + + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + + +@pytest.fixture() +def auth_client(auth_session): + def _override(): + yield auth_session + + app.dependency_overrides[get_session] = _override + with TestClient(app) as client: + yield client + app.dependency_overrides.clear() + + +def _public_jwk(public_key, kid: str) -> dict[str, object]: + jwk = json.loads(algorithms.RSAAlgorithm.to_jwk(public_key)) + jwk["kid"] = kid + jwk["use"] = "sig" + jwk["alg"] = "RS256" + return jwk + + +def _sign_token(private_key, kid: str, **claims_overrides: object) -> str: + now = datetime.now(UTC) + payload: dict[str, object] = { + "iss": "https://auth.example.test", + "sub": "user-123", + "aud": "innercontext-web", + "exp": int((now + timedelta(hours=1)).timestamp()), + "iat": int(now.timestamp()), + "groups": ["innercontext-admin"], + "email": "user@example.test", + "name": "Inner Context User", + "preferred_username": "ictx-user", + } + payload.update(claims_overrides) + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) + + +def _mock_oidc(monkeypatch, public_key, *, fetch_counts: dict[str, int] | None = None): + def _fake_get(url: str, timeout: float): + if fetch_counts is not None: + fetch_counts[url] = fetch_counts.get(url, 0) + 1 + if url.endswith("/.well-known/openid-configuration"): + return _MockResponse({"jwks_uri": "https://auth.example.test/jwks.json"}) + if url.endswith("/jwks.json"): + return _MockResponse({"keys": [_public_jwk(public_key, "kid-1")]}) + raise AssertionError(f"unexpected URL {url} with timeout {timeout}") + + monkeypatch.setattr("innercontext.auth.httpx.get", _fake_get) + + +def test_validate_access_token_uses_cached_jwks(auth_env, rsa_keypair, monkeypatch): + private_key, public_key = rsa_keypair + fetch_counts: dict[str, int] = {} + _mock_oidc(monkeypatch, public_key, fetch_counts=fetch_counts) + + validate_access_token(_sign_token(private_key, "kid-1", sub="user-a")) + validate_access_token(_sign_token(private_key, "kid-1", sub="user-b")) + + assert ( + fetch_counts["https://auth.example.test/.well-known/openid-configuration"] == 1 + ) + assert fetch_counts["https://auth.example.test/jwks.json"] == 1 + + +@pytest.mark.parametrize( + ("path", "payload"), + [ + ( + "/auth/session/sync", + { + "email": "sync@example.test", + "name": "Synced User", + "preferred_username": "synced-user", + "groups": ["innercontext-admin"], + }, + ), + ("/auth/me", None), + ], + ids=["/auth/session/sync", "/auth/me"], +) +def test_sync_protected_endpoints_create_or_resolve_current_user( + auth_env, + auth_client, + auth_session, + rsa_keypair, + monkeypatch, + path: str, + payload: dict[str, object] | None, +): + private_key, public_key = rsa_keypair + _mock_oidc(monkeypatch, public_key) + token = _sign_token(private_key, "kid-1") + + if path == "/auth/me": + user = User( + oidc_issuer="https://auth.example.test", + oidc_subject="user-123", + role=Role.ADMIN, + ) + auth_session.add(user) + auth_session.commit() + auth_session.refresh(user) + + household = Household() + auth_session.add(household) + auth_session.commit() + auth_session.refresh(household) + + membership = HouseholdMembership( + user_id=user.id, + household_id=household.id, + role=HouseholdRole.OWNER, + ) + auth_session.add(membership) + auth_session.commit() + + response = auth_client.request( + "POST" if path.endswith("sync") else "GET", + path, + headers={"Authorization": f"Bearer {token}"}, + json=payload, + ) + + assert response.status_code == 200 + data = response.json() + assert data["user"]["role"] == "admin" + assert data["identity"]["issuer"] == "https://auth.example.test" + assert data["identity"]["subject"] == "user-123" + + synced_user = auth_session.get(User, UUID(data["user"]["id"])) + assert synced_user is not None + assert synced_user.oidc_issuer == "https://auth.example.test" + assert synced_user.oidc_subject == "user-123" + + if path == "/auth/session/sync": + assert data["identity"]["email"] == "sync@example.test" + assert data["identity"]["groups"] == ["innercontext-admin"] + else: + assert data["user"]["household_membership"]["role"] == "owner" + + +@pytest.mark.parametrize( + "path", + ["/auth/me", "/profile"], + ids=["/auth/me expects 401", "/profile expects 401"], +) +def test_unauthorized_protected_endpoints_return_401(auth_env, auth_client, path: str): + response = auth_client.get(path) + assert response.status_code == 401 + assert response.json()["detail"] == "Missing bearer token" + + +def test_unauthorized_invalid_bearer_token_is_rejected( + auth_env, auth_client, rsa_keypair, monkeypatch +): + _, public_key = rsa_keypair + _mock_oidc(monkeypatch, public_key) + response = auth_client.get( + "/auth/me", + headers={"Authorization": "Bearer not-a-jwt"}, + ) + assert response.status_code == 401 + + +def test_require_admin_raises_for_member(): + claims = TokenClaims( + issuer="https://auth.example.test", + subject="member-1", + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + raw_claims={"iss": "https://auth.example.test", "sub": "member-1"}, + ) + current_user = CurrentUser( + user_id=uuid4(), + role=Role.MEMBER, + identity=IdentityData.from_claims(claims), + claims=claims, + household_membership=CurrentHouseholdMembership( + household_id=uuid4(), + role=HouseholdRole.MEMBER, + ), + ) + + with pytest.raises(HTTPException) as exc_info: + require_admin(current_user) + + assert exc_info.value.status_code == 403 From 1f47974f4826f4b4f5ebeeff3c56d1a6c9e461ac Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 15:26:06 +0100 Subject: [PATCH 02/10] refactor(api): centralize tenant authorization helpers --- backend/innercontext/api/authz.py | 173 ++++++++++++++++++ backend/innercontext/api/utils.py | 46 +++++ backend/tests/test_authz.py | 293 ++++++++++++++++++++++++++++++ 3 files changed, 512 insertions(+) create mode 100644 backend/innercontext/api/authz.py create mode 100644 backend/tests/test_authz.py diff --git a/backend/innercontext/api/authz.py b/backend/innercontext/api/authz.py new file mode 100644 index 0000000..23f71dc --- /dev/null +++ b/backend/innercontext/api/authz.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from typing import TypeVar, cast +from uuid import UUID + +from fastapi import HTTPException +from sqlmodel import Session, select + +from innercontext.auth import CurrentUser +from innercontext.models import HouseholdMembership, Product, ProductInventory, Role + +_T = TypeVar("_T") + + +def _not_found(model_name: str) -> HTTPException: + return HTTPException(status_code=404, detail=f"{model_name} not found") + + +def _user_scoped_model_name(model: type[object]) -> str: + return getattr(model, "__name__", str(model)) + + +def _record_user_id(model: type[object], record: object) -> object: + if not hasattr(record, "user_id"): + model_name = _user_scoped_model_name(model) + raise TypeError(f"{model_name} does not expose user_id") + return cast(object, getattr(record, "user_id")) + + +def _is_admin(current_user: CurrentUser) -> bool: + return current_user.role is Role.ADMIN + + +def _owner_household_id(session: Session, owner_user_id: UUID) -> UUID | None: + membership = session.exec( + select(HouseholdMembership).where(HouseholdMembership.user_id == owner_user_id) + ).first() + if membership is None: + return None + return membership.household_id + + +def _is_same_household( + session: Session, + owner_user_id: UUID, + current_user: CurrentUser, +) -> bool: + if current_user.household_membership is None: + return False + owner_household_id = _owner_household_id(session, owner_user_id) + return owner_household_id == current_user.household_membership.household_id + + +def get_owned_or_404( + session: Session, + model: type[_T], + record_id: object, + current_user: CurrentUser, +) -> _T: + obj = session.get(model, record_id) + model_name = _user_scoped_model_name(model) + if obj is None: + raise _not_found(model_name) + if _record_user_id(model, obj) != current_user.user_id: + raise _not_found(model_name) + return obj + + +def get_owned_or_404_admin_override( + session: Session, + model: type[_T], + record_id: object, + current_user: CurrentUser, +) -> _T: + obj = session.get(model, record_id) + model_name = _user_scoped_model_name(model) + if obj is None: + raise _not_found(model_name) + if _is_admin(current_user): + return obj + if _record_user_id(model, obj) != current_user.user_id: + raise _not_found(model_name) + return obj + + +def list_owned( + session: Session, model: type[_T], current_user: CurrentUser +) -> list[_T]: + model_name = _user_scoped_model_name(model) + if not hasattr(model, "user_id"): + raise TypeError(f"{model_name} does not expose user_id") + records = cast(list[_T], session.exec(select(model)).all()) + return [ + record + for record in records + if _record_user_id(model, record) == current_user.user_id + ] + + +def list_owned_admin_override( + session: Session, + model: type[_T], + current_user: CurrentUser, +) -> list[_T]: + if _is_admin(current_user): + statement = select(model) + return cast(list[_T], session.exec(statement).all()) + return list_owned(session, model, current_user) + + +def check_household_inventory_access( + session: Session, + inventory_id: UUID, + current_user: CurrentUser, +) -> ProductInventory: + inventory = session.get(ProductInventory, inventory_id) + if inventory is None: + raise _not_found(ProductInventory.__name__) + + if _is_admin(current_user): + return inventory + + owner_user_id = inventory.user_id + if owner_user_id == current_user.user_id: + return inventory + + if not inventory.is_household_shared or owner_user_id is None: + raise _not_found(ProductInventory.__name__) + + if not _is_same_household(session, owner_user_id, current_user): + raise _not_found(ProductInventory.__name__) + + return inventory + + +def can_update_inventory( + session: Session, + inventory_id: UUID, + current_user: CurrentUser, +) -> bool: + inventory = session.get(ProductInventory, inventory_id) + if inventory is None: + return False + if _is_admin(current_user): + return True + return inventory.user_id == current_user.user_id + + +def is_product_visible( + session: Session, product_id: UUID, current_user: CurrentUser +) -> bool: + product = session.get(Product, product_id) + if product is None: + return False + + if _is_admin(current_user): + return True + + if product.user_id == current_user.user_id: + return True + + if current_user.household_membership is None: + return False + + inventories = session.exec( + select(ProductInventory).where(ProductInventory.product_id == product_id) + ).all() + for inventory in inventories: + if not inventory.is_household_shared or inventory.user_id is None: + continue + if _is_same_household(session, inventory.user_id, current_user): + return True + return False diff --git a/backend/innercontext/api/utils.py b/backend/innercontext/api/utils.py index 6321f07..af40248 100644 --- a/backend/innercontext/api/utils.py +++ b/backend/innercontext/api/utils.py @@ -3,6 +3,18 @@ from typing import TypeVar from fastapi import HTTPException from sqlmodel import Session +from innercontext.api.authz import ( + get_owned_or_404 as authz_get_owned_or_404, +) +from innercontext.api.authz import ( + get_owned_or_404_admin_override as authz_get_owned_or_404_admin_override, +) +from innercontext.api.authz import list_owned as authz_list_owned +from innercontext.api.authz import ( + list_owned_admin_override as authz_list_owned_admin_override, +) +from innercontext.auth import CurrentUser + _T = TypeVar("_T") @@ -11,3 +23,37 @@ def get_or_404(session: Session, model: type[_T], record_id: object) -> _T: if obj is None: raise HTTPException(status_code=404, detail=f"{model.__name__} not found") return obj + + +def get_owned_or_404( + session: Session, + model: type[_T], + record_id: object, + current_user: CurrentUser, +) -> _T: + return authz_get_owned_or_404(session, model, record_id, current_user) + + +def get_owned_or_404_admin_override( + session: Session, + model: type[_T], + record_id: object, + current_user: CurrentUser, +) -> _T: + return authz_get_owned_or_404_admin_override( + session, model, record_id, current_user + ) + + +def list_owned( + session: Session, model: type[_T], current_user: CurrentUser +) -> list[_T]: + return authz_list_owned(session, model, current_user) + + +def list_owned_admin_override( + session: Session, + model: type[_T], + current_user: CurrentUser, +) -> list[_T]: + return authz_list_owned_admin_override(session, model, current_user) diff --git a/backend/tests/test_authz.py b/backend/tests/test_authz.py new file mode 100644 index 0000000..d870678 --- /dev/null +++ b/backend/tests/test_authz.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest +from fastapi import HTTPException +from sqlmodel import Session + +from innercontext.api.authz import ( + can_update_inventory, + check_household_inventory_access, + get_owned_or_404, + get_owned_or_404_admin_override, + is_product_visible, + list_owned, + list_owned_admin_override, +) +from innercontext.auth import ( + CurrentHouseholdMembership, + CurrentUser, + IdentityData, + TokenClaims, +) +from innercontext.models import ( + Household, + HouseholdMembership, + HouseholdRole, + DayTime, + MedicationEntry, + MedicationKind, + Product, + ProductCategory, + ProductInventory, + Role, +) + + +def _claims(subject: str) -> TokenClaims: + return TokenClaims( + issuer="https://auth.example.test", + subject=subject, + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + raw_claims={"iss": "https://auth.example.test", "sub": subject}, + ) + + +def _current_user( + user_id: UUID, + *, + role: Role = Role.MEMBER, + household_id: UUID | None = None, +) -> CurrentUser: + claims = _claims(str(user_id)) + membership = None + if household_id is not None: + membership = CurrentHouseholdMembership( + household_id=household_id, + role=HouseholdRole.MEMBER, + ) + return CurrentUser( + user_id=user_id, + role=role, + identity=IdentityData.from_claims(claims), + claims=claims, + household_membership=membership, + ) + + +def _create_household(session: Session) -> Household: + household = Household() + session.add(household) + session.commit() + session.refresh(household) + return household + + +def _create_membership( + session: Session, user_id: UUID, household_id: UUID +) -> HouseholdMembership: + membership = HouseholdMembership(user_id=user_id, household_id=household_id) + session.add(membership) + session.commit() + session.refresh(membership) + return membership + + +def _create_medication(session: Session, user_id: UUID) -> MedicationEntry: + entry = MedicationEntry( + user_id=user_id, + kind=MedicationKind.PRESCRIPTION, + product_name="Test medication", + ) + session.add(entry) + session.commit() + session.refresh(entry) + return entry + + +def _create_product(session: Session, user_id: UUID, short_id: str) -> Product: + product = Product( + user_id=user_id, + short_id=short_id, + name="Shared product", + brand="Test brand", + category=ProductCategory.MOISTURIZER, + recommended_time=DayTime.BOTH, + leave_on=True, + ) + setattr(product, "product_effect_profile", {}) + session.add(product) + session.commit() + session.refresh(product) + return product + + +def _create_inventory( + session: Session, + *, + user_id: UUID, + product_id: UUID, + is_household_shared: bool, +) -> ProductInventory: + inventory = ProductInventory( + user_id=user_id, + product_id=product_id, + is_household_shared=is_household_shared, + ) + session.add(inventory) + session.commit() + session.refresh(inventory) + return inventory + + +def test_owner_helpers_return_only_owned_records(session: Session): + owner_id = uuid4() + other_id = uuid4() + owner_user = _current_user(owner_id) + owner_entry = _create_medication(session, owner_id) + _ = _create_medication(session, other_id) + + fetched = get_owned_or_404( + session, MedicationEntry, owner_entry.record_id, owner_user + ) + owned_entries = list_owned(session, MedicationEntry, owner_user) + + assert fetched.record_id == owner_entry.record_id + assert len(owned_entries) == 1 + assert owned_entries[0].user_id == owner_id + + +def test_admin_helpers_allow_admin_override_for_lookup_and_list(session: Session): + owner_id = uuid4() + admin_user = _current_user(uuid4(), role=Role.ADMIN) + owner_entry = _create_medication(session, owner_id) + + fetched = get_owned_or_404_admin_override( + session, + MedicationEntry, + owner_entry.record_id, + admin_user, + ) + listed = list_owned_admin_override(session, MedicationEntry, admin_user) + + assert fetched.record_id == owner_entry.record_id + assert len(listed) == 1 + + +def test_owner_denied_for_non_owned_lookup_returns_404(session: Session): + owner_id = uuid4() + intruder = _current_user(uuid4()) + owner_entry = _create_medication(session, owner_id) + + with pytest.raises(HTTPException) as exc_info: + _ = get_owned_or_404(session, MedicationEntry, owner_entry.record_id, intruder) + + assert exc_info.value.status_code == 404 + + +def test_household_shared_inventory_access_allows_same_household_member( + session: Session, +): + owner_id = uuid4() + household_member_id = uuid4() + household = _create_household(session) + _ = _create_membership(session, owner_id, household.id) + _ = _create_membership(session, household_member_id, household.id) + + product = _create_product(session, owner_id, short_id="abcd0001") + inventory = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + current_user = _current_user(household_member_id, household_id=household.id) + fetched = check_household_inventory_access(session, inventory.id, current_user) + + assert fetched.id == inventory.id + + +def test_household_shared_inventory_denied_for_cross_household_member(session: Session): + owner_id = uuid4() + outsider_id = uuid4() + owner_household = _create_household(session) + outsider_household = _create_household(session) + _ = _create_membership(session, owner_id, owner_household.id) + _ = _create_membership(session, outsider_id, outsider_household.id) + + product = _create_product(session, owner_id, short_id="abcd0002") + inventory = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + outsider = _current_user(outsider_id, household_id=outsider_household.id) + + with pytest.raises(HTTPException) as exc_info: + _ = check_household_inventory_access(session, inventory.id, outsider) + + assert exc_info.value.status_code == 404 + + +def test_household_inventory_update_rules_owner_admin_and_member(session: Session): + owner_id = uuid4() + member_id = uuid4() + household = _create_household(session) + _ = _create_membership(session, owner_id, household.id) + _ = _create_membership(session, member_id, household.id) + + product = _create_product(session, owner_id, short_id="abcd0003") + inventory = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + owner = _current_user(owner_id, household_id=household.id) + admin = _current_user(uuid4(), role=Role.ADMIN) + member = _current_user(member_id, household_id=household.id) + + assert can_update_inventory(session, inventory.id, owner) is True + assert can_update_inventory(session, inventory.id, admin) is True + assert can_update_inventory(session, inventory.id, member) is False + + +def test_product_visibility_for_owner_admin_and_household_shared(session: Session): + owner_id = uuid4() + member_id = uuid4() + household = _create_household(session) + _ = _create_membership(session, owner_id, household.id) + _ = _create_membership(session, member_id, household.id) + + product = _create_product(session, owner_id, short_id="abcd0004") + _ = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + owner = _current_user(owner_id, household_id=household.id) + admin = _current_user(uuid4(), role=Role.ADMIN) + member = _current_user(member_id, household_id=household.id) + + assert is_product_visible(session, product.id, owner) is True + assert is_product_visible(session, product.id, admin) is True + assert is_product_visible(session, product.id, member) is True + + +def test_product_visibility_denied_for_cross_household_member(session: Session): + owner_id = uuid4() + outsider_id = uuid4() + owner_household = _create_household(session) + outsider_household = _create_household(session) + _ = _create_membership(session, owner_id, owner_household.id) + _ = _create_membership(session, outsider_id, outsider_household.id) + + product = _create_product(session, owner_id, short_id="abcd0005") + _ = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + outsider = _current_user(outsider_id, household_id=outsider_household.id) + + assert is_product_visible(session, product.id, outsider) is False From 803bc3b4cdf911fd24b9edc40daa04157ef24f08 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 15:37:39 +0100 Subject: [PATCH 03/10] feat(api): scope products and inventory by owner and household --- backend/innercontext/api/authz.py | 6 +- backend/innercontext/api/inventory.py | 34 ++- backend/innercontext/api/products.py | 152 ++++++++--- backend/tests/test_authz.py | 2 +- backend/tests/test_products_auth.py | 370 ++++++++++++++++++++++++++ 5 files changed, 520 insertions(+), 44 deletions(-) create mode 100644 backend/tests/test_products_auth.py diff --git a/backend/innercontext/api/authz.py b/backend/innercontext/api/authz.py index 23f71dc..82558e3 100644 --- a/backend/innercontext/api/authz.py +++ b/backend/innercontext/api/authz.py @@ -143,7 +143,11 @@ def can_update_inventory( return False if _is_admin(current_user): return True - return inventory.user_id == current_user.user_id + if inventory.user_id == current_user.user_id: + return True + if not inventory.is_household_shared or inventory.user_id is None: + return False + return _is_same_household(session, inventory.user_id, current_user) def is_product_visible( diff --git a/backend/innercontext/api/inventory.py b/backend/innercontext/api/inventory.py index 9d50034..6c3d797 100644 --- a/backend/innercontext/api/inventory.py +++ b/backend/innercontext/api/inventory.py @@ -1,19 +1,29 @@ from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.api.authz import ( + can_update_inventory, + check_household_inventory_access, +) from innercontext.api.products import InventoryUpdate -from innercontext.api.utils import get_or_404 +from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override +from innercontext.auth import CurrentUser from innercontext.models import ProductInventory router = APIRouter() @router.get("/{inventory_id}", response_model=ProductInventory) -def get_inventory(inventory_id: UUID, session: Session = Depends(get_session)): - return get_or_404(session, ProductInventory, inventory_id) +def get_inventory( + inventory_id: UUID, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + return check_household_inventory_access(session, inventory_id, current_user) @router.patch("/{inventory_id}", response_model=ProductInventory) @@ -21,7 +31,10 @@ def update_inventory( inventory_id: UUID, data: InventoryUpdate, session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): + if not can_update_inventory(session, inventory_id, current_user): + raise HTTPException(status_code=404, detail="ProductInventory not found") entry = get_or_404(session, ProductInventory, inventory_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(entry, key, value) @@ -32,7 +45,16 @@ def update_inventory( @router.delete("/{inventory_id}", status_code=204) -def delete_inventory(inventory_id: UUID, session: Session = Depends(get_session)): - entry = get_or_404(session, ProductInventory, inventory_id) +def delete_inventory( + inventory_id: UUID, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + entry = get_owned_or_404_admin_override( + session, + ProductInventory, + inventory_id, + current_user, + ) session.delete(entry) session.commit() diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 7391a42..b453c14 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,3 +1,5 @@ +# pyright: reportImportCycles=false, reportIncompatibleVariableOverride=false + import json import logging from datetime import date @@ -13,6 +15,8 @@ from sqlalchemy import select as sa_select from sqlmodel import Field, Session, SQLModel, col, select from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.api.authz import is_product_visible from innercontext.api.llm_context import build_user_profile_context from innercontext.api.product_llm_tools import ( PRODUCT_DETAILS_FUNCTION_DECLARATION, @@ -24,7 +28,8 @@ from innercontext.api.product_llm_tools import ( build_last_used_on_by_product, build_product_details_tool_handler, ) -from innercontext.api.utils import get_or_404 +from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override +from innercontext.auth import CurrentUser from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -42,6 +47,7 @@ from innercontext.models import ( SkinConcern, SkinConditionSnapshot, ) +from innercontext.models import Role from innercontext.models.ai_log import AICallLog from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics from innercontext.models.enums import ( @@ -67,6 +73,34 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _is_inventory_visible_to_user( + inventory: ProductInventory, + session: Session, + current_user: CurrentUser, +) -> bool: + if current_user.role is Role.ADMIN: + return True + if inventory.user_id == current_user.user_id: + return True + if not inventory.is_household_shared: + return False + if inventory.user_id is None: + return False + return is_product_visible(session, inventory.product_id, current_user) + + +def _visible_inventory_for_product( + inventories: list[ProductInventory], + session: Session, + current_user: CurrentUser, +) -> list[ProductInventory]: + return [ + inventory + for inventory in inventories + if _is_inventory_visible_to_user(inventory, session, current_user) + ] + + def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None: """Build ResponseMetadata from AICallLog for Phase 3 observability.""" if not log_id: @@ -214,15 +248,15 @@ class ProductListItem(SQLModel): class AIActiveIngredient(ActiveIngredient): # Gemini API rejects int-enum values in response_schema; override with plain int. - strength_level: Optional[int] = None # type: ignore[assignment] - irritation_potential: Optional[int] = None # type: ignore[assignment] + strength_level: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride] + irritation_potential: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride] class ProductParseLLMResponse(ProductParseResponse): # Gemini response schema currently requires enum values to be strings. # Strength fields are numeric in our domain (1-3), so keep them as ints here # and convert via ProductParseResponse validation afterward. - actives: Optional[list[AIActiveIngredient]] = None # type: ignore[assignment] + actives: Optional[list[AIActiveIngredient]] = None # pyright: ignore[reportIncompatibleVariableOverride] class InventoryCreate(SQLModel): @@ -610,6 +644,7 @@ def list_products( is_medication: Optional[bool] = None, is_tool: Optional[bool] = None, session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): stmt = select(Product) if category is not None: @@ -622,6 +657,12 @@ def list_products( stmt = stmt.where(Product.is_tool == is_tool) products = list(session.exec(stmt).all()) + if current_user.role is not Role.ADMIN: + products = [ + product + for product in products + if is_product_visible(session, product.id, current_user) + ] # Filter by targets (JSON column — done in Python) if targets: @@ -646,20 +687,28 @@ def list_products( if product_ids else [] ) - inv_by_product: dict = {} + inv_by_product: dict[UUID, list[ProductInventory]] = {} for inv in inventory_rows: inv_by_product.setdefault(inv.product_id, []).append(inv) results = [] for p in products: r = ProductWithInventory.model_validate(p, from_attributes=True) - r.inventory = inv_by_product.get(p.id, []) + r.inventory = _visible_inventory_for_product( + inv_by_product.get(p.id, []), + session, + current_user, + ) results.append(r) return results @router.post("", response_model=ProductPublic, status_code=201) -def create_product(data: ProductCreate, session: Session = Depends(get_session)): +def create_product( + data: ProductCreate, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): payload = data.model_dump() if payload.get("price_currency"): payload["price_currency"] = str(payload["price_currency"]).upper() @@ -667,6 +716,7 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session)) product_id = uuid4() product = Product( id=product_id, + user_id=current_user.user_id, short_id=str(product_id)[:8], **payload, ) @@ -849,10 +899,12 @@ def list_products_summary( is_medication: Optional[bool] = None, is_tool: Optional[bool] = None, session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): product_table = inspect(Product).local_table stmt = sa_select( product_table.c.id, + product_table.c.user_id, product_table.c.name, product_table.c.brand, product_table.c.category, @@ -872,6 +924,10 @@ def list_products_summary( stmt = stmt.where(product_table.c.is_tool == is_tool) rows = list(session.execute(stmt).all()) + if current_user.role is not Role.ADMIN: + rows = [ + row for row in rows if is_product_visible(session, row[0], current_user) + ] if targets: target_values = {t.value for t in targets} @@ -884,26 +940,11 @@ def list_products_summary( ) ] - product_ids = [row[0] for row in rows] - inventory_rows = ( - session.exec( - select(ProductInventory).where( - col(ProductInventory.product_id).in_(product_ids) - ) - ).all() - if product_ids - else [] - ) - owned_ids = { - inv.product_id - for inv in inventory_rows - if inv.product_id is not None and inv.finished_at is None - } - results: list[ProductListItem] = [] for row in rows: ( product_id, + product_user_id, name, brand_value, category_value, @@ -921,7 +962,7 @@ def list_products_summary( category=category_value, recommended_time=recommended_time, targets=row_targets or [], - is_owned=product_id in owned_ids, + is_owned=product_user_id == current_user.user_id, price_tier=price_tier, price_per_use_pln=price_per_use_pln, price_tier_source=price_tier_source, @@ -932,22 +973,35 @@ def list_products_summary( @router.get("/{product_id}", response_model=ProductWithInventory) -def get_product(product_id: UUID, session: Session = Depends(get_session)): +def get_product( + product_id: UUID, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): product = get_or_404(session, Product, product_id) + if not is_product_visible(session, product_id, current_user): + raise HTTPException(status_code=404, detail="Product not found") inventory = session.exec( select(ProductInventory).where(ProductInventory.product_id == product_id) ).all() result = ProductWithInventory.model_validate(product, from_attributes=True) - result.inventory = list(inventory) + result.inventory = _visible_inventory_for_product( + list(inventory), session, current_user + ) return result @router.patch("/{product_id}", response_model=ProductPublic) def update_product( - product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session) + product_id: UUID, + data: ProductUpdate, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - product = get_or_404(session, Product, product_id) + product = get_owned_or_404_admin_override( + session, Product, product_id, current_user + ) patch_data = data.model_dump(exclude_unset=True) if patch_data.get("price_currency"): patch_data["price_currency"] = str(patch_data["price_currency"]).upper() @@ -962,8 +1016,14 @@ def update_product( @router.delete("/{product_id}", status_code=204) -def delete_product(product_id: UUID, session: Session = Depends(get_session)): - product = get_or_404(session, Product, product_id) +def delete_product( + product_id: UUID, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + product = get_owned_or_404_admin_override( + session, Product, product_id, current_user + ) session.delete(product) enqueue_pricing_recalc(session) session.commit() @@ -975,10 +1035,17 @@ def delete_product(product_id: UUID, session: Session = Depends(get_session)): @router.get("/{product_id}/inventory", response_model=list[ProductInventory]) -def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)): +def list_product_inventory( + product_id: UUID, + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): get_or_404(session, Product, product_id) + if not is_product_visible(session, product_id, current_user): + raise HTTPException(status_code=404, detail="Product not found") stmt = select(ProductInventory).where(ProductInventory.product_id == product_id) - return session.exec(stmt).all() + inventories = list(session.exec(stmt).all()) + return _visible_inventory_for_product(inventories, session, current_user) @router.post( @@ -988,10 +1055,14 @@ def create_product_inventory( product_id: UUID, data: InventoryCreate, session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - get_or_404(session, Product, product_id) + product = get_owned_or_404_admin_override( + session, Product, product_id, current_user + ) entry = ProductInventory( id=uuid4(), + user_id=product.user_id or current_user.user_id, product_id=product_id, **data.model_dump(), ) @@ -1018,11 +1089,16 @@ def _ev(v: object) -> str: def _build_shopping_context( session: Session, reference_date: date, + current_user: CurrentUser, *, products: list[Product] | None = None, last_used_on_by_product: dict[str, date] | None = None, ) -> str: - profile_ctx = build_user_profile_context(session, reference_date=reference_date) + profile_ctx = build_user_profile_context( + session, + reference_date=reference_date, + current_user=current_user, + ) snapshot = session.exec( select(SkinConditionSnapshot).order_by( col(SkinConditionSnapshot.snapshot_date).desc() @@ -1061,7 +1137,7 @@ def _build_shopping_context( if product_ids else [] ) - inv_by_product: dict = {} + inv_by_product: dict[UUID, list[ProductInventory]] = {} for inv in inventory_rows: inv_by_product.setdefault(inv.product_id, []).append(inv) @@ -1213,7 +1289,10 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" @router.post("/suggest", response_model=ShoppingSuggestionResponse) -def suggest_shopping(session: Session = Depends(get_session)): +def suggest_shopping( + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): reference_date = date.today() shopping_products = _get_shopping_products(session) last_used_on_by_product = build_last_used_on_by_product( @@ -1223,6 +1302,7 @@ def suggest_shopping(session: Session = Depends(get_session)): context = _build_shopping_context( session, reference_date=reference_date, + current_user=current_user, products=shopping_products, last_used_on_by_product=last_used_on_by_product, ) diff --git a/backend/tests/test_authz.py b/backend/tests/test_authz.py index d870678..34a59c0 100644 --- a/backend/tests/test_authz.py +++ b/backend/tests/test_authz.py @@ -246,7 +246,7 @@ def test_household_inventory_update_rules_owner_admin_and_member(session: Sessio assert can_update_inventory(session, inventory.id, owner) is True assert can_update_inventory(session, inventory.id, admin) is True - assert can_update_inventory(session, inventory.id, member) is False + assert can_update_inventory(session, inventory.id, member) is True def test_product_visibility_for_owner_admin_and_household_shared(session: Session): diff --git a/backend/tests/test_products_auth.py b/backend/tests/test_products_auth.py new file mode 100644 index 0000000..c5cc18a --- /dev/null +++ b/backend/tests/test_products_auth.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import ( + CurrentHouseholdMembership, + CurrentUser, + IdentityData, + TokenClaims, +) +from innercontext.models import ( + DayTime, + Household, + HouseholdMembership, + HouseholdRole, + Product, + ProductCategory, + ProductInventory, + Role, +) +from main import app + + +def _current_user( + user_id: UUID, + *, + role: Role = Role.MEMBER, + household_id: UUID | None = None, +) -> CurrentUser: + claims = TokenClaims( + issuer="https://auth.test", + subject=str(user_id), + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + groups=("innercontext-member",), + raw_claims={"iss": "https://auth.test", "sub": str(user_id)}, + ) + membership = None + if household_id is not None: + membership = CurrentHouseholdMembership( + household_id=household_id, + role=HouseholdRole.MEMBER, + ) + return CurrentUser( + user_id=user_id, + role=role, + identity=IdentityData.from_claims(claims), + claims=claims, + household_membership=membership, + ) + + +def _create_membership(session, user_id: UUID, household_id: UUID) -> None: + membership = HouseholdMembership( + user_id=user_id, + household_id=household_id, + role=HouseholdRole.MEMBER, + ) + session.add(membership) + session.commit() + + +def _create_product(session, *, user_id: UUID, short_id: str, name: str) -> Product: + product = Product( + user_id=user_id, + short_id=short_id, + name=name, + brand="Brand", + category=ProductCategory.SERUM, + recommended_time=DayTime.BOTH, + leave_on=True, + ) + setattr(product, "product_effect_profile", {}) + session.add(product) + session.commit() + session.refresh(product) + return product + + +def _create_inventory( + session, + *, + user_id: UUID, + product_id: UUID, + is_household_shared: bool, +) -> ProductInventory: + entry = ProductInventory( + user_id=user_id, + product_id=product_id, + is_household_shared=is_household_shared, + ) + session.add(entry) + session.commit() + session.refresh(entry) + return entry + + +@pytest.fixture() +def auth_client(session): + auth_state = {"current_user": _current_user(uuid4(), role=Role.ADMIN)} + + def _session_override(): + yield session + + def _current_user_override(): + return auth_state["current_user"] + + app.dependency_overrides[get_session] = _session_override + app.dependency_overrides[get_current_user] = _current_user_override + with TestClient(app) as client: + yield client, auth_state + app.dependency_overrides.clear() + + +def test_product_endpoints_require_authentication(session): + def _session_override(): + yield session + + app.dependency_overrides[get_session] = _session_override + app.dependency_overrides.pop(get_current_user, None) + with TestClient(app) as client: + response = client.get("/products") + app.dependency_overrides.clear() + + assert response.status_code == 401 + assert response.json()["detail"] == "Missing bearer token" + + +def test_shared_product_visible_in_summary_marks_is_owned_false(auth_client, session): + client, auth_state = auth_client + + owner_id = uuid4() + member_id = uuid4() + household = Household() + session.add(household) + session.commit() + session.refresh(household) + _create_membership(session, owner_id, household.id) + _create_membership(session, member_id, household.id) + + shared_product = _create_product( + session, + user_id=owner_id, + short_id="shprd001", + name="Shared Product", + ) + _ = _create_inventory( + session, + user_id=owner_id, + product_id=shared_product.id, + is_household_shared=True, + ) + + auth_state["current_user"] = _current_user(member_id, household_id=household.id) + response = client.get("/products/summary") + + assert response.status_code == 200 + items = response.json() + shared_item = next(item for item in items if item["id"] == str(shared_product.id)) + assert shared_item["is_owned"] is False + + +def test_shared_product_visible_filters_private_inventory_rows(auth_client, session): + client, auth_state = auth_client + + owner_id = uuid4() + member_id = uuid4() + household = Household() + session.add(household) + session.commit() + session.refresh(household) + _create_membership(session, owner_id, household.id) + _create_membership(session, member_id, household.id) + + product = _create_product( + session, + user_id=owner_id, + short_id="shprd002", + name="Shared Inventory Product", + ) + shared_row = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + _ = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=False, + ) + + auth_state["current_user"] = _current_user(member_id, household_id=household.id) + response = client.get(f"/products/{product.id}") + + assert response.status_code == 200 + inventory_ids = {entry["id"] for entry in response.json()["inventory"]} + assert str(shared_row.id) in inventory_ids + assert len(inventory_ids) == 1 + + +def test_shared_inventory_update_allows_household_member(auth_client, session): + client, auth_state = auth_client + + owner_id = uuid4() + member_id = uuid4() + household = Household() + session.add(household) + session.commit() + session.refresh(household) + _create_membership(session, owner_id, household.id) + _create_membership(session, member_id, household.id) + + product = _create_product( + session, + user_id=owner_id, + short_id="shprd003", + name="Shared Update Product", + ) + inventory = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + auth_state["current_user"] = _current_user(member_id, household_id=household.id) + response = client.patch( + f"/inventory/{inventory.id}", + json={"is_opened": True, "remaining_level": "low"}, + ) + + assert response.status_code == 200 + assert response.json()["is_opened"] is True + assert response.json()["remaining_level"] == "low" + + +def test_household_member_cannot_edit_shared_product(auth_client, session): + client, auth_state = auth_client + + owner_id = uuid4() + member_id = uuid4() + household = Household() + session.add(household) + session.commit() + session.refresh(household) + _create_membership(session, owner_id, household.id) + _create_membership(session, member_id, household.id) + + product = _create_product( + session, + user_id=owner_id, + short_id="shprd004", + name="Shared No Edit", + ) + _ = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + auth_state["current_user"] = _current_user(member_id, household_id=household.id) + response = client.patch(f"/products/{product.id}", json={"name": "Intrusion"}) + + assert response.status_code == 404 + + +def test_household_member_cannot_delete_shared_product(auth_client, session): + client, auth_state = auth_client + + owner_id = uuid4() + member_id = uuid4() + household = Household() + session.add(household) + session.commit() + session.refresh(household) + _create_membership(session, owner_id, household.id) + _create_membership(session, member_id, household.id) + + product = _create_product( + session, + user_id=owner_id, + short_id="shprd005", + name="Shared No Delete", + ) + _ = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + auth_state["current_user"] = _current_user(member_id, household_id=household.id) + response = client.delete(f"/products/{product.id}") + + assert response.status_code == 404 + + +def test_household_member_cannot_create_or_delete_inventory_on_shared_product( + auth_client, session +): + client, auth_state = auth_client + + owner_id = uuid4() + member_id = uuid4() + household = Household() + session.add(household) + session.commit() + session.refresh(household) + _create_membership(session, owner_id, household.id) + _create_membership(session, member_id, household.id) + + product = _create_product( + session, + user_id=owner_id, + short_id="shprd006", + name="Shared Inventory Restrictions", + ) + inventory = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=True, + ) + + auth_state["current_user"] = _current_user(member_id, household_id=household.id) + create_response = client.post(f"/products/{product.id}/inventory", json={}) + delete_response = client.delete(f"/inventory/{inventory.id}") + + assert create_response.status_code == 404 + assert delete_response.status_code == 404 + + +def test_household_member_cannot_update_non_shared_inventory(auth_client, session): + client, auth_state = auth_client + + owner_id = uuid4() + member_id = uuid4() + household = Household() + session.add(household) + session.commit() + session.refresh(household) + _create_membership(session, owner_id, household.id) + _create_membership(session, member_id, household.id) + + product = _create_product( + session, + user_id=owner_id, + short_id="shprd007", + name="Private Inventory", + ) + inventory = _create_inventory( + session, + user_id=owner_id, + product_id=product.id, + is_household_shared=False, + ) + + auth_state["current_user"] = _current_user(member_id, household_id=household.id) + response = client.patch(f"/inventory/{inventory.id}", json={"is_opened": True}) + + assert response.status_code == 404 From cd8e39939a5491d91118b9080b240bdaf3a79fc2 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 15:40:55 +0100 Subject: [PATCH 04/10] feat(frontend): add Authelia OIDC session flow --- frontend/src/app.d.ts | 8 +- frontend/src/hooks.server.ts | 36 +- frontend/src/lib/server/auth.ts | 695 +++++++++++++++++++ frontend/src/routes/auth/callback/+server.ts | 30 + frontend/src/routes/auth/login/+server.ts | 8 + frontend/src/routes/auth/logout/+server.ts | 10 + 6 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/server/auth.ts create mode 100644 frontend/src/routes/auth/callback/+server.ts create mode 100644 frontend/src/routes/auth/login/+server.ts create mode 100644 frontend/src/routes/auth/logout/+server.ts diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 520c421..33ab713 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,9 +1,15 @@ +import type { AppSession } from "$lib/server/auth"; +import type { AuthUserPublic } from "$lib/api/generated/types.gen"; + // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + session: AppSession | null; + user: AuthUserPublic | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index cb5906d..1983535 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,6 +1,38 @@ import { paraglideMiddleware } from "$lib/paraglide/server.js"; -import type { Handle } from "@sveltejs/kit"; +import { + buildLoginPath, + getRequestSession, + isBackendRequest, + isProtectedPath, + loadAuthenticatedSession, +} from "$lib/server/auth"; +import type { Handle, HandleFetch } from "@sveltejs/kit"; +import { redirect } from "@sveltejs/kit"; export const handle: Handle = async ({ event, resolve }) => { - return paraglideMiddleware(event.request, () => resolve(event)); + return paraglideMiddleware(event.request, async () => { + const session = await loadAuthenticatedSession(event); + event.locals.session = session; + event.locals.user = session?.user ?? null; + + if (!session && isProtectedPath(event.url.pathname)) { + throw redirect(303, buildLoginPath(event.url)); + } + + return resolve(event); + }); +}; + +export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { + const session = getRequestSession(event); + if (!session || !isBackendRequest(new URL(request.url), event)) { + return fetch(request); + } + + const headers = new Headers(request.headers); + if (!headers.has("authorization")) { + headers.set("authorization", `Bearer ${session.accessToken}`); + } + + return fetch(new Request(request, { headers })); }; diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts new file mode 100644 index 0000000..41f4154 --- /dev/null +++ b/frontend/src/lib/server/auth.ts @@ -0,0 +1,695 @@ +import { dev } from "$app/environment"; +import { env as privateEnv } from "$env/dynamic/private"; +import { env as publicEnv } from "$env/dynamic/public"; +import type { Cookies, RequestEvent } from "@sveltejs/kit"; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + timingSafeEqual, +} from "node:crypto"; +import type { + AuthIdentityPublic, + AuthProfilePublic, + AuthSessionResponse, + AuthUserPublic, +} from "$lib/api/generated/types.gen"; + +const AUTH_COOKIE_NAME = "innercontext_session"; +const LOGIN_COOKIE_NAME = "innercontext_login"; +const DISCOVERY_PATH = "/.well-known/openid-configuration"; +const AUTH_FLOW_TTL_SECONDS = 600; +const SESSION_REFRESH_WINDOW_SECONDS = 60; +const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; +const DEFAULT_API_BASE = "http://localhost:8000"; +const DEFAULT_SCOPES = "openid profile email groups offline_access"; +const CIPHER_ALGORITHM = "aes-256-gcm"; +const CURRENT_VERSION = 1; + +type SameSite = "lax" | "strict" | "none"; + +interface AuthConfig { + issuer: string; + clientId: string; + clientSecret: string | null; + discoveryUrl: string; + scopes: string; + sessionSecret: string; +} + +interface DiscoveryDocument { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + end_session_endpoint?: string; +} + +interface TokenResponse { + access_token: string; + expires_in?: number; + refresh_token?: string; + refresh_token_expires_in?: number; + refresh_expires_in?: number; + token_type?: string; + scope?: string; + id_token?: string; +} + +interface UserInfoClaims { + iss?: string; + sub: string; + email?: string | null; + name?: string | null; + preferred_username?: string | null; + groups?: string[]; +} + +interface LoginFlowState { + state: string; + codeVerifier: string; + returnTo: string; +} + +export interface AppSession { + accessToken: string; + refreshToken: string | null; + tokenType: string; + scope: string | null; + expiresAt: number; + refreshExpiresAt: number | null; + user: AuthUserPublic; + identity: AuthIdentityPublic; + profile: AuthProfilePublic | null; +} + +interface SerializedPayload { + v: number; + data: T; +} + +let cachedDiscovery: Promise | null = null; + +function getAuthConfig(): AuthConfig { + const issuer = requiredEnv("OIDC_ISSUER"); + + return { + issuer, + clientId: requiredEnv("OIDC_CLIENT_ID"), + clientSecret: optionalEnv("OIDC_CLIENT_SECRET"), + discoveryUrl: + optionalEnv("OIDC_DISCOVERY_URL") ?? + `${issuer.replace(/\/+$/, "")}${DISCOVERY_PATH}`, + scopes: optionalEnv("OIDC_SCOPES") ?? DEFAULT_SCOPES, + sessionSecret: requiredEnv("SESSION_SECRET"), + }; +} + +function requiredEnv(name: string): string { + const value = privateEnv[name]?.trim(); + if (!value) { + throw new Error(`Missing required auth environment variable: ${name}`); + } + return value; +} + +function optionalEnv(name: string): string | null { + const value = privateEnv[name]?.trim(); + return value ? value : null; +} + +function getApiBase(): string { + return publicEnv.PUBLIC_API_BASE?.trim() || DEFAULT_API_BASE; +} + +function cookieOptions(maxAge: number) { + return { + path: "/", + httpOnly: true, + sameSite: "lax" as SameSite, + secure: !dev, + maxAge, + }; +} + +function getSecretKey(): Buffer { + const configured = getAuthConfig().sessionSecret; + + if (/^[0-9a-fA-F]{64}$/.test(configured)) { + return Buffer.from(configured, "hex"); + } + + if (/^[A-Za-z0-9_-]{43,44}$/.test(configured)) { + const decoded = Buffer.from(configured, "base64url"); + if (decoded.length === 32) { + return decoded; + } + } + + const utf8 = Buffer.from(configured, "utf8"); + if (utf8.length >= 32) { + return createHash("sha256").update(utf8).digest(); + } + + throw new Error( + "SESSION_SECRET must contain at least 32 bytes or encode exactly 32 bytes", + ); +} + +function encryptValue(value: T): string { + const key = getSecretKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(CIPHER_ALGORITHM, key, iv); + const plaintext = Buffer.from( + JSON.stringify({ + v: CURRENT_VERSION, + data: value, + } satisfies SerializedPayload), + "utf8", + ); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([ + Buffer.from([CURRENT_VERSION]), + iv, + authTag, + ciphertext, + ]).toString("base64url"); +} + +function decryptValue(value: string): T | null { + try { + const payload = Buffer.from(value, "base64url"); + if (payload.length < 29 || payload[0] !== CURRENT_VERSION) { + return null; + } + + const iv = payload.subarray(1, 13); + const authTag = payload.subarray(13, 29); + const ciphertext = payload.subarray(29); + const decipher = createDecipheriv(CIPHER_ALGORITHM, getSecretKey(), iv); + decipher.setAuthTag(authTag); + + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString("utf8"); + const parsed = JSON.parse(plaintext) as SerializedPayload; + if (parsed.v !== CURRENT_VERSION) { + return null; + } + return parsed.data; + } catch { + return null; + } +} + +function nowInSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function getSessionCookieMaxAge(session: AppSession): number { + const expiresAt = + session.refreshExpiresAt ?? + (session.refreshToken + ? nowInSeconds() + DEFAULT_SESSION_MAX_AGE_SECONDS + : session.expiresAt); + return Math.max(expiresAt - nowInSeconds(), 0); +} + +function sanitizeReturnTo(value: string | null | undefined): string { + if (!value) { + return "/"; + } + + if (!value.startsWith("/") || value.startsWith("//")) { + return "/"; + } + + if (value.startsWith("/auth")) { + return "/"; + } + + return value; +} + +function normalizeGroups(value: unknown): string[] | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.map((entry) => String(entry)); + } + return [String(value)]; +} + +function decodeJwtExpiry(token: string): number | null { + try { + const [, payload] = token.split("."); + if (!payload) { + return null; + } + const decoded = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as { + exp?: unknown; + }; + return typeof decoded.exp === "number" ? decoded.exp : null; + } catch { + return null; + } +} + +function sessionNeedsRefresh(session: AppSession): boolean { + return session.expiresAt - nowInSeconds() <= SESSION_REFRESH_WINDOW_SECONDS; +} + +function stateMatches(expected: string, actual: string): boolean { + const expectedBuffer = Buffer.from(expected, "utf8"); + const actualBuffer = Buffer.from(actual, "utf8"); + if (expectedBuffer.length !== actualBuffer.length) { + return false; + } + return timingSafeEqual(expectedBuffer, actualBuffer); +} + +function getRedirectUri(url: URL): string { + return new URL("/auth/callback", url.origin).toString(); +} + +function getLogoutReturnUri(url: URL): string { + return new URL("/", url.origin).toString(); +} + +async function parseJsonResponse(response: Response): Promise { + const text = await response.text(); + if (!text) { + return null; + } + return JSON.parse(text) as unknown; +} + +async function requestJson(input: string, init?: RequestInit): Promise { + const response = await fetch(input, init); + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(text || response.statusText); + } + return (await parseJsonResponse(response)) as T; +} + +async function getDiscoveryDocument(): Promise { + cachedDiscovery ??= requestJson( + getAuthConfig().discoveryUrl, + ).catch((error) => { + cachedDiscovery = null; + throw error; + }); + return cachedDiscovery; +} + +function buildAuthorizationUrl( + url: URL, + state: string, + codeChallenge: string, +): Promise { + return getDiscoveryDocument().then((discovery) => { + const authUrl = new URL(discovery.authorization_endpoint); + const config = getAuthConfig(); + authUrl.searchParams.set("client_id", config.clientId); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("redirect_uri", getRedirectUri(url)); + authUrl.searchParams.set("scope", config.scopes); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + return authUrl; + }); +} + +function createPkceVerifier(): string { + return randomBytes(32).toString("base64url"); +} + +function createPkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function createState(): string { + return randomBytes(24).toString("base64url"); +} + +function buildTokenRequestBody( + values: Record, +): URLSearchParams { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + body.set(key, value); + } + + const config = getAuthConfig(); + body.set("client_id", config.clientId); + if (config.clientSecret) { + body.set("client_secret", config.clientSecret); + } + + return body; +} + +async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string, +): Promise { + const discovery = await getDiscoveryDocument(); + return requestJson(discovery.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: buildTokenRequestBody({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); +} + +async function refreshToken(refreshToken: string): Promise { + const discovery = await getDiscoveryDocument(); + return requestJson(discovery.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: buildTokenRequestBody({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); +} + +async function fetchUserInfo(accessToken: string): Promise { + const discovery = await getDiscoveryDocument(); + const payload = await requestJson>( + discovery.userinfo_endpoint, + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + + const subject = payload.sub; + if (typeof subject !== "string" || !subject) { + throw new Error("OIDC userinfo payload is missing sub"); + } + + return { + iss: typeof payload.iss === "string" ? payload.iss : undefined, + sub: subject, + email: typeof payload.email === "string" ? payload.email : null, + name: typeof payload.name === "string" ? payload.name : null, + preferred_username: + typeof payload.preferred_username === "string" + ? payload.preferred_username + : null, + groups: normalizeGroups(payload.groups), + }; +} + +async function syncBackendSession( + accessToken: string, + claims: UserInfoClaims, +): Promise { + return requestJson(`${getApiBase()}/auth/session/sync`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + iss: claims.iss ?? getAuthConfig().issuer, + sub: claims.sub, + email: claims.email, + name: claims.name, + preferred_username: claims.preferred_username, + groups: claims.groups, + }), + }); +} + +function buildAppSession( + tokenResponse: TokenResponse, + backendSession: AuthSessionResponse, + previousSession?: AppSession, +): AppSession { + const currentTime = nowInSeconds(); + const accessToken = tokenResponse.access_token; + const accessTokenExpiry = + typeof tokenResponse.expires_in === "number" + ? currentTime + tokenResponse.expires_in + : decodeJwtExpiry(accessToken); + + if (!accessTokenExpiry) { + throw new Error( + "OIDC token response is missing access token expiry information", + ); + } + + const refreshExpiresIn = + typeof tokenResponse.refresh_token_expires_in === "number" + ? tokenResponse.refresh_token_expires_in + : typeof tokenResponse.refresh_expires_in === "number" + ? tokenResponse.refresh_expires_in + : null; + + return { + accessToken, + refreshToken: + tokenResponse.refresh_token ?? previousSession?.refreshToken ?? null, + tokenType: + tokenResponse.token_type ?? previousSession?.tokenType ?? "Bearer", + scope: tokenResponse.scope ?? previousSession?.scope ?? null, + expiresAt: accessTokenExpiry, + refreshExpiresAt: + refreshExpiresIn !== null + ? currentTime + refreshExpiresIn + : (previousSession?.refreshExpiresAt ?? null), + user: backendSession.user, + identity: backendSession.identity, + profile: backendSession.profile ?? null, + }; +} + +export function loadSession(cookies: Cookies): AppSession | null { + const raw = cookies.get(AUTH_COOKIE_NAME); + if (!raw) { + return null; + } + return decryptValue(raw); +} + +export function setSessionCookie(cookies: Cookies, session: AppSession): void { + cookies.set( + AUTH_COOKIE_NAME, + encryptValue(session), + cookieOptions(Math.max(getSessionCookieMaxAge(session), 1)), + ); +} + +export function clearSessionCookie(cookies: Cookies): void { + cookies.delete(AUTH_COOKIE_NAME, { path: "/" }); +} + +function loadLoginFlow(cookies: Cookies): LoginFlowState | null { + const raw = cookies.get(LOGIN_COOKIE_NAME); + if (!raw) { + return null; + } + return decryptValue(raw); +} + +function setLoginFlowCookie(cookies: Cookies, flow: LoginFlowState): void { + cookies.set( + LOGIN_COOKIE_NAME, + encryptValue(flow), + cookieOptions(AUTH_FLOW_TTL_SECONDS), + ); +} + +export function clearLoginFlowCookie(cookies: Cookies): void { + cookies.delete(LOGIN_COOKIE_NAME, { path: "/" }); +} + +export function clearAuthCookies(cookies: Cookies): void { + clearSessionCookie(cookies); + clearLoginFlowCookie(cookies); +} + +export async function createLoginRedirect(event: RequestEvent): Promise { + const returnTo = sanitizeReturnTo(event.url.searchParams.get("returnTo")); + const codeVerifier = createPkceVerifier(); + const state = createState(); + + setLoginFlowCookie(event.cookies, { + state, + codeVerifier, + returnTo, + }); + + return buildAuthorizationUrl( + event.url, + state, + createPkceChallenge(codeVerifier), + ); +} + +export async function finishLogin(event: RequestEvent): Promise { + const flow = loadLoginFlow(event.cookies); + clearLoginFlowCookie(event.cookies); + + if (!flow) { + throw new Error("Missing or invalid login flow state"); + } + + const state = event.url.searchParams.get("state"); + const code = event.url.searchParams.get("code"); + + if (!state || !stateMatches(flow.state, state)) { + throw new Error("OIDC callback state check failed"); + } + + if (!code) { + throw new Error("OIDC callback is missing authorization code"); + } + + const tokenResponse = await exchangeCodeForTokens( + code, + flow.codeVerifier, + getRedirectUri(event.url), + ); + const userInfo = await fetchUserInfo(tokenResponse.access_token); + const backendSession = await syncBackendSession( + tokenResponse.access_token, + userInfo, + ); + const session = buildAppSession(tokenResponse, backendSession); + setSessionCookie(event.cookies, session); + + return flow.returnTo; +} + +export async function refreshSession(session: AppSession): Promise { + if (!session.refreshToken) { + throw new Error("App session cannot be refreshed without a refresh token"); + } + + if ( + session.refreshExpiresAt !== null && + session.refreshExpiresAt <= nowInSeconds() + ) { + throw new Error("Refresh token has expired"); + } + + const tokenResponse = await refreshToken(session.refreshToken); + const userInfo = await fetchUserInfo(tokenResponse.access_token); + const backendSession = await syncBackendSession( + tokenResponse.access_token, + userInfo, + ); + return buildAppSession(tokenResponse, backendSession, session); +} + +export async function loadAuthenticatedSession( + event: RequestEvent, +): Promise { + const session = loadSession(event.cookies); + if (!session && event.cookies.get(AUTH_COOKIE_NAME)) { + clearAuthCookies(event.cookies); + return null; + } + + if (!session) { + return null; + } + + if (!sessionNeedsRefresh(session)) { + return session; + } + + try { + const refreshed = await refreshSession(session); + setSessionCookie(event.cookies, refreshed); + return refreshed; + } catch { + clearAuthCookies(event.cookies); + return null; + } +} + +export function isProtectedPath(pathname: string): boolean { + if (pathname.startsWith("/auth")) { + return false; + } + if (pathname.startsWith("/_app") || pathname.startsWith("/api")) { + return false; + } + if ( + pathname === "/favicon.ico" || + pathname === "/robots.txt" || + pathname === "/manifest.webmanifest" + ) { + return false; + } + return !/\.[a-zA-Z0-9]+$/.test(pathname); +} + +export function buildLoginPath(url: URL): string { + const loginUrl = new URL("/auth/login", url.origin); + const returnTo = sanitizeReturnTo(`${url.pathname}${url.search}`); + if (returnTo !== "/") { + loginUrl.searchParams.set("returnTo", returnTo); + } + return `${loginUrl.pathname}${loginUrl.search}`; +} + +export async function buildLogoutUrl(url: URL): Promise { + let discovery: DiscoveryDocument; + try { + discovery = await getDiscoveryDocument(); + } catch { + return "/auth/login"; + } + + if (!discovery.end_session_endpoint) { + return "/auth/login"; + } + + const logoutUrl = new URL(discovery.end_session_endpoint); + logoutUrl.searchParams.set("client_id", getAuthConfig().clientId); + logoutUrl.searchParams.set( + "post_logout_redirect_uri", + getLogoutReturnUri(url), + ); + return logoutUrl.toString(); +} + +export function getRequestSession(event: RequestEvent): AppSession | null { + return event.locals.session ?? null; +} + +export function isBackendRequest(url: URL, event: RequestEvent): boolean { + if (url.origin === event.url.origin && url.pathname.startsWith("/api")) { + return true; + } + + const apiBase = getApiBase(); + try { + const backendUrl = new URL(apiBase); + return ( + url.origin === backendUrl.origin && + url.pathname.startsWith(backendUrl.pathname) + ); + } catch { + return false; + } +} diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..88261c7 --- /dev/null +++ b/frontend/src/routes/auth/callback/+server.ts @@ -0,0 +1,30 @@ +import { clearLoginFlowCookie, finishLogin } from "$lib/server/auth"; +import { error, redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async (event) => { + const providerError = event.url.searchParams.get("error"); + if (providerError) { + clearLoginFlowCookie(event.cookies); + const providerErrorDescription = + event.url.searchParams.get("error_description"); + throw error( + 400, + providerErrorDescription + ? `${providerError}: ${providerErrorDescription}` + : `OIDC callback failed: ${providerError}`, + ); + } + + let returnTo: string; + try { + returnTo = await finishLogin(event); + } catch (cause) { + throw error( + 400, + cause instanceof Error ? cause.message : "OIDC callback failed", + ); + } + + throw redirect(303, returnTo); +}; diff --git a/frontend/src/routes/auth/login/+server.ts b/frontend/src/routes/auth/login/+server.ts new file mode 100644 index 0000000..604db3d --- /dev/null +++ b/frontend/src/routes/auth/login/+server.ts @@ -0,0 +1,8 @@ +import { createLoginRedirect } from "$lib/server/auth"; +import { redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async (event) => { + const loginUrl = await createLoginRedirect(event); + throw redirect(303, loginUrl.toString()); +}; diff --git a/frontend/src/routes/auth/logout/+server.ts b/frontend/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..94540b7 --- /dev/null +++ b/frontend/src/routes/auth/logout/+server.ts @@ -0,0 +1,10 @@ +import { buildLogoutUrl, clearAuthCookies } from "$lib/server/auth"; +import { redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async (event) => { + clearAuthCookies(event.cookies); + + const logoutUrl = await buildLogoutUrl(event.url); + throw redirect(303, logoutUrl); +}; From ffa3b71309f3ad62b1f2872f2050365c85b26b50 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 15:48:13 +0100 Subject: [PATCH 05/10] feat(api): enforce ownership across health routines and profile flows --- backend/innercontext/api/ai_logs.py | 36 +- backend/innercontext/api/health.py | 271 ++++++++--- backend/innercontext/api/llm_context.py | 55 ++- backend/innercontext/api/profile.py | 25 +- backend/innercontext/api/routines.py | 438 +++++++++++++++--- backend/innercontext/api/skincare.py | 100 +++- backend/innercontext/services/pricing_jobs.py | 47 +- backend/tests/conftest.py | 35 +- backend/tests/test_ai_logs.py | 6 +- backend/tests/test_routines.py | 11 +- backend/tests/test_routines_auth.py | 112 +++++ backend/tests/test_routines_helpers.py | 193 ++++++-- backend/tests/test_skincare.py | 2 +- backend/tests/test_tenancy_domains.py | 100 ++++ 14 files changed, 1225 insertions(+), 206 deletions(-) create mode 100644 backend/tests/test_routines_auth.py create mode 100644 backend/tests/test_tenancy_domains.py diff --git a/backend/innercontext/api/ai_logs.py b/backend/innercontext/api/ai_logs.py index 040d47e..b407006 100644 --- a/backend/innercontext/api/ai_logs.py +++ b/backend/innercontext/api/ai_logs.py @@ -2,10 +2,13 @@ import json from typing import Any, Optional from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, SQLModel, col, select from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import CurrentUser +from innercontext.models.enums import Role from innercontext.models.ai_log import AICallLog router = APIRouter() @@ -43,14 +46,33 @@ class AICallLogPublic(SQLModel): error_detail: Optional[str] = None +def _resolve_target_user_id( + current_user: CurrentUser, + user_id: UUID | None, +) -> UUID: + if user_id is None: + return current_user.user_id + if current_user.role is not Role.ADMIN: + raise HTTPException(status_code=403, detail="Admin role required") + return user_id + + @router.get("", response_model=list[AICallLogPublic]) def list_ai_logs( endpoint: Optional[str] = None, success: Optional[bool] = None, limit: int = 50, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - stmt = select(AICallLog).order_by(col(AICallLog.created_at).desc()).limit(limit) + target_user_id = _resolve_target_user_id(current_user, user_id) + stmt = ( + select(AICallLog) + .where(AICallLog.user_id == target_user_id) + .order_by(col(AICallLog.created_at).desc()) + .limit(limit) + ) if endpoint is not None: stmt = stmt.where(AICallLog.endpoint == endpoint) if success is not None: @@ -75,9 +97,17 @@ def list_ai_logs( @router.get("/{log_id}", response_model=AICallLog) -def get_ai_log(log_id: UUID, session: Session = Depends(get_session)): +def get_ai_log( + log_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) log = session.get(AICallLog, log_id) if log is None: raise HTTPException(status_code=404, detail="Log not found") + if log.user_id != target_user_id: + raise HTTPException(status_code=404, detail="Log not found") log.tool_trace = _normalize_tool_trace(getattr(log, "tool_trace", None)) return log diff --git a/backend/innercontext/api/health.py b/backend/innercontext/api/health.py index d3e8064..9f2334e 100644 --- a/backend/innercontext/api/health.py +++ b/backend/innercontext/api/health.py @@ -3,15 +3,17 @@ from datetime import datetime from typing import Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import field_validator from sqlalchemy import Integer, cast, func, or_ from sqlmodel import Session, SQLModel, col, select from db import get_session -from innercontext.api.utils import get_or_404 +from innercontext.api.auth_deps import get_current_user +from innercontext.api.utils import get_owned_or_404 +from innercontext.auth import CurrentUser from innercontext.models import LabResult, MedicationEntry, MedicationUsage -from innercontext.models.enums import MedicationKind, ResultFlag +from innercontext.models.enums import MedicationKind, ResultFlag, Role router = APIRouter() @@ -133,6 +135,34 @@ class LabResultListResponse(SQLModel): # --------------------------------------------------------------------------- +def _resolve_target_user_id( + current_user: CurrentUser, + user_id: UUID | None, +) -> UUID: + if user_id is None: + return current_user.user_id + if current_user.role is not Role.ADMIN: + raise HTTPException(status_code=403, detail="Admin role required") + return user_id + + +def _get_owned_or_admin_override( + session: Session, + model: type[MedicationEntry] | type[MedicationUsage] | type[LabResult], + record_id: UUID, + current_user: CurrentUser, + user_id: UUID | None, +): + if user_id is None: + return get_owned_or_404(session, model, record_id, current_user) + record = session.get(model, record_id) + if record is None or record.user_id != _resolve_target_user_id( + current_user, user_id + ): + raise HTTPException(status_code=404, detail=f"{model.__name__} not found") + return record + + # --------------------------------------------------------------------------- # Medication routes # --------------------------------------------------------------------------- @@ -142,9 +172,12 @@ class LabResultListResponse(SQLModel): def list_medications( kind: Optional[MedicationKind] = None, product_name: Optional[str] = None, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - stmt = select(MedicationEntry) + target_user_id = _resolve_target_user_id(current_user, user_id) + stmt = select(MedicationEntry).where(MedicationEntry.user_id == target_user_id) if kind is not None: stmt = stmt.where(MedicationEntry.kind == kind) if product_name is not None: @@ -153,8 +186,18 @@ def list_medications( @router.post("/medications", response_model=MedicationEntry, status_code=201) -def create_medication(data: MedicationCreate, session: Session = Depends(get_session)): - entry = MedicationEntry(record_id=uuid4(), **data.model_dump()) +def create_medication( + data: MedicationCreate, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + entry = MedicationEntry( + record_id=uuid4(), + user_id=target_user_id, + **data.model_dump(), + ) session.add(entry) session.commit() session.refresh(entry) @@ -162,17 +205,36 @@ def create_medication(data: MedicationCreate, session: Session = Depends(get_ses @router.get("/medications/{medication_id}", response_model=MedicationEntry) -def get_medication(medication_id: UUID, session: Session = Depends(get_session)): - return get_or_404(session, MedicationEntry, medication_id) +def get_medication( + medication_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + return _get_owned_or_admin_override( + session, + MedicationEntry, + medication_id, + current_user, + user_id, + ) @router.patch("/medications/{medication_id}", response_model=MedicationEntry) def update_medication( medication_id: UUID, data: MedicationUpdate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - entry = get_or_404(session, MedicationEntry, medication_id) + entry = _get_owned_or_admin_override( + session, + MedicationEntry, + medication_id, + current_user, + user_id, + ) for key, value in data.model_dump(exclude_unset=True).items(): setattr(entry, key, value) session.add(entry) @@ -182,13 +244,25 @@ def update_medication( @router.delete("/medications/{medication_id}", status_code=204) -def delete_medication(medication_id: UUID, session: Session = Depends(get_session)): - entry = get_or_404(session, MedicationEntry, medication_id) +def delete_medication( + medication_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + entry = _get_owned_or_admin_override( + session, + MedicationEntry, + medication_id, + current_user, + user_id, + ) # Delete usages first (no cascade configured at DB level) usages = session.exec( - select(MedicationUsage).where( - MedicationUsage.medication_record_id == medication_id - ) + select(MedicationUsage) + .where(MedicationUsage.medication_record_id == medication_id) + .where(MedicationUsage.user_id == target_user_id) ).all() for u in usages: session.delete(u) @@ -202,10 +276,24 @@ def delete_medication(medication_id: UUID, session: Session = Depends(get_sessio @router.get("/medications/{medication_id}/usages", response_model=list[MedicationUsage]) -def list_usages(medication_id: UUID, session: Session = Depends(get_session)): - get_or_404(session, MedicationEntry, medication_id) - stmt = select(MedicationUsage).where( - MedicationUsage.medication_record_id == medication_id +def list_usages( + medication_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + _ = _get_owned_or_admin_override( + session, + MedicationEntry, + medication_id, + current_user, + user_id, + ) + stmt = ( + select(MedicationUsage) + .where(MedicationUsage.medication_record_id == medication_id) + .where(MedicationUsage.user_id == target_user_id) ) return session.exec(stmt).all() @@ -218,11 +306,21 @@ def list_usages(medication_id: UUID, session: Session = Depends(get_session)): def create_usage( medication_id: UUID, data: UsageCreate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - get_or_404(session, MedicationEntry, medication_id) + target_user_id = _resolve_target_user_id(current_user, user_id) + _ = _get_owned_or_admin_override( + session, + MedicationEntry, + medication_id, + current_user, + user_id, + ) usage = MedicationUsage( record_id=uuid4(), + user_id=target_user_id, medication_record_id=medication_id, **data.model_dump(), ) @@ -236,9 +334,17 @@ def create_usage( def update_usage( usage_id: UUID, data: UsageUpdate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - usage = get_or_404(session, MedicationUsage, usage_id) + usage = _get_owned_or_admin_override( + session, + MedicationUsage, + usage_id, + current_user, + user_id, + ) for key, value in data.model_dump(exclude_unset=True).items(): setattr(usage, key, value) session.add(usage) @@ -248,8 +354,19 @@ def update_usage( @router.delete("/usages/{usage_id}", status_code=204) -def delete_usage(usage_id: UUID, session: Session = Depends(get_session)): - usage = get_or_404(session, MedicationUsage, usage_id) +def delete_usage( + usage_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + usage = _get_owned_or_admin_override( + session, + MedicationUsage, + usage_id, + current_user, + user_id, + ) session.delete(usage) session.commit() @@ -271,29 +388,35 @@ def list_lab_results( latest_only: bool = False, limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - filters = [] - if q is not None and q.strip(): - query = f"%{q.strip()}%" - filters.append( - or_( - col(LabResult.test_code).ilike(query), - col(LabResult.test_name_original).ilike(query), + target_user_id = _resolve_target_user_id(current_user, user_id) + + def _apply_filters(statement): + statement = statement.where(col(LabResult.user_id) == target_user_id) + if q is not None and q.strip(): + query = f"%{q.strip()}%" + statement = statement.where( + or_( + col(LabResult.test_code).ilike(query), + col(LabResult.test_name_original).ilike(query), + ) ) - ) - if test_code is not None: - filters.append(LabResult.test_code == test_code) - if flag is not None: - filters.append(LabResult.flag == flag) - if flags: - filters.append(col(LabResult.flag).in_(flags)) - if without_flag: - filters.append(col(LabResult.flag).is_(None)) - if from_date is not None: - filters.append(LabResult.collected_at >= from_date) - if to_date is not None: - filters.append(LabResult.collected_at <= to_date) + if test_code is not None: + statement = statement.where(col(LabResult.test_code) == test_code) + if flag is not None: + statement = statement.where(col(LabResult.flag) == flag) + if flags: + statement = statement.where(col(LabResult.flag).in_(flags)) + if without_flag: + statement = statement.where(col(LabResult.flag).is_(None)) + if from_date is not None: + statement = statement.where(col(LabResult.collected_at) >= from_date) + if to_date is not None: + statement = statement.where(col(LabResult.collected_at) <= to_date) + return statement if latest_only: ranked_stmt = select( @@ -309,8 +432,7 @@ def list_lab_results( ) .label("rank"), ) - if filters: - ranked_stmt = ranked_stmt.where(*filters) + ranked_stmt = _apply_filters(ranked_stmt) ranked_subquery = ranked_stmt.subquery() latest_ids = select(ranked_subquery.c.record_id).where( @@ -323,11 +445,8 @@ def list_lab_results( .subquery() ) else: - stmt = select(LabResult) - count_stmt = select(func.count()).select_from(LabResult) - if filters: - stmt = stmt.where(*filters) - count_stmt = count_stmt.where(*filters) + stmt = _apply_filters(select(LabResult)) + count_stmt = _apply_filters(select(func.count()).select_from(LabResult)) test_code_numeric = cast( func.replace(col(LabResult.test_code), "-", ""), @@ -345,8 +464,18 @@ def list_lab_results( @router.post("/lab-results", response_model=LabResult, status_code=201) -def create_lab_result(data: LabResultCreate, session: Session = Depends(get_session)): - result = LabResult(record_id=uuid4(), **data.model_dump()) +def create_lab_result( + data: LabResultCreate, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + result = LabResult( + record_id=uuid4(), + user_id=target_user_id, + **data.model_dump(), + ) session.add(result) session.commit() session.refresh(result) @@ -354,17 +483,36 @@ def create_lab_result(data: LabResultCreate, session: Session = Depends(get_sess @router.get("/lab-results/{result_id}", response_model=LabResult) -def get_lab_result(result_id: UUID, session: Session = Depends(get_session)): - return get_or_404(session, LabResult, result_id) +def get_lab_result( + result_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + return _get_owned_or_admin_override( + session, + LabResult, + result_id, + current_user, + user_id, + ) @router.patch("/lab-results/{result_id}", response_model=LabResult) def update_lab_result( result_id: UUID, data: LabResultUpdate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - result = get_or_404(session, LabResult, result_id) + result = _get_owned_or_admin_override( + session, + LabResult, + result_id, + current_user, + user_id, + ) for key, value in data.model_dump(exclude_unset=True).items(): setattr(result, key, value) session.add(result) @@ -374,7 +522,18 @@ def update_lab_result( @router.delete("/lab-results/{result_id}", status_code=204) -def delete_lab_result(result_id: UUID, session: Session = Depends(get_session)): - result = get_or_404(session, LabResult, result_id) +def delete_lab_result( + result_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + result = _get_owned_or_admin_override( + session, + LabResult, + result_id, + current_user, + user_id, + ) session.delete(result) session.commit() diff --git a/backend/innercontext/api/llm_context.py b/backend/innercontext/api/llm_context.py index 6ffc68e..40e7dcf 100644 --- a/backend/innercontext/api/llm_context.py +++ b/backend/innercontext/api/llm_context.py @@ -2,14 +2,41 @@ from datetime import date from typing import Any from uuid import UUID +from fastapi import HTTPException from sqlmodel import Session, col, select +from innercontext.auth import CurrentUser from innercontext.models import Product, UserProfile +from innercontext.models.enums import Role -def get_user_profile(session: Session) -> UserProfile | None: +def _resolve_target_user_id( + current_user: CurrentUser, + user_id: UUID | None, +) -> UUID: + if user_id is None: + return current_user.user_id + if current_user.role is not Role.ADMIN: + raise HTTPException(status_code=403, detail="Admin role required") + return user_id + + +def get_user_profile( + session: Session, + current_user: CurrentUser | None = None, + *, + user_id: UUID | None = None, +) -> UserProfile | None: + if current_user is None: + return session.exec( + select(UserProfile).order_by(col(UserProfile.created_at).desc()) + ).first() + + target_user_id = _resolve_target_user_id(current_user, user_id) return session.exec( - select(UserProfile).order_by(col(UserProfile.created_at).desc()) + select(UserProfile) + .where(UserProfile.user_id == target_user_id) + .order_by(col(UserProfile.created_at).desc()) ).first() @@ -20,8 +47,14 @@ def calculate_age(birth_date: date, reference_date: date) -> int: return years -def build_user_profile_context(session: Session, reference_date: date) -> str: - profile = get_user_profile(session) +def build_user_profile_context( + session: Session, + reference_date: date, + current_user: CurrentUser | None = None, + *, + user_id: UUID | None = None, +) -> str: + profile = get_user_profile(session, current_user, user_id=user_id) if profile is None: return "USER PROFILE: no data\n" @@ -69,8 +102,9 @@ def build_product_context_summary(product: Product, has_inventory: bool = False) # Get effect profile scores if available effects = [] - if hasattr(product, "effect_profile") and product.effect_profile: - profile = product.effect_profile + effect_profile = getattr(product, "effect_profile", None) + if effect_profile: + profile = effect_profile # Only include notable effects (score > 0) # Handle both dict (from DB) and object (from Pydantic) if isinstance(profile, dict): @@ -165,11 +199,12 @@ def build_product_context_detailed( # Effect profile effect_profile = None - if hasattr(product, "effect_profile") and product.effect_profile: - if isinstance(product.effect_profile, dict): - effect_profile = product.effect_profile + product_effect_profile = getattr(product, "effect_profile", None) + if product_effect_profile: + if isinstance(product_effect_profile, dict): + effect_profile = product_effect_profile else: - effect_profile = product.effect_profile.model_dump() + effect_profile = product_effect_profile.model_dump() # Context rules context_rules = None diff --git a/backend/innercontext/api/profile.py b/backend/innercontext/api/profile.py index 52e8e14..ebadbd4 100644 --- a/backend/innercontext/api/profile.py +++ b/backend/innercontext/api/profile.py @@ -1,11 +1,14 @@ from datetime import date, datetime from typing import Optional +from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlmodel import Session, SQLModel from db import get_session from innercontext.api.llm_context import get_user_profile +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import CurrentUser from innercontext.models import SexAtBirth, UserProfile router = APIRouter() @@ -25,8 +28,12 @@ class UserProfilePublic(SQLModel): @router.get("", response_model=UserProfilePublic | None) -def get_profile(session: Session = Depends(get_session)): - profile = get_user_profile(session) +def get_profile( + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + profile = get_user_profile(session, current_user, user_id=user_id) if profile is None: return None return UserProfilePublic( @@ -39,12 +46,18 @@ def get_profile(session: Session = Depends(get_session)): @router.patch("", response_model=UserProfilePublic) -def upsert_profile(data: UserProfileUpdate, session: Session = Depends(get_session)): - profile = get_user_profile(session) +def upsert_profile( + data: UserProfileUpdate, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = user_id if user_id is not None else current_user.user_id + profile = get_user_profile(session, current_user, user_id=user_id) payload = data.model_dump(exclude_unset=True) if profile is None: - profile = UserProfile(**payload) + profile = UserProfile(user_id=target_user_id, **payload) else: for key, value in payload.items(): setattr(profile, key, value) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 9f98b27..3b825ff 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -5,12 +5,15 @@ from datetime import date, timedelta from typing import Any, Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from google.genai import types as genai_types from pydantic import BaseModel as PydanticBase +from sqlalchemy import or_ from sqlmodel import Field, Session, SQLModel, col, select from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.api.authz import is_product_visible from innercontext.api.llm_context import ( build_products_context_summary_list, build_user_profile_context, @@ -25,7 +28,8 @@ from innercontext.api.product_llm_tools import ( build_last_used_on_by_product, build_product_details_tool_handler, ) -from innercontext.api.utils import get_or_404 +from innercontext.api.utils import get_owned_or_404 +from innercontext.auth import CurrentUser from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -33,6 +37,7 @@ from innercontext.llm import ( ) from innercontext.llm_safety import isolate_user_input, sanitize_user_input from innercontext.models import ( + HouseholdMembership, GroomingSchedule, Product, ProductInventory, @@ -43,6 +48,7 @@ from innercontext.models import ( from innercontext.models.ai_log import AICallLog from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics from innercontext.models.enums import GroomingAction, PartOfDay +from innercontext.models.enums import Role from innercontext.validators import BatchValidator, RoutineSuggestionValidator from innercontext.validators.batch_validator import BatchValidationContext from innercontext.validators.routine_validator import RoutineValidationContext @@ -86,6 +92,47 @@ def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata router = APIRouter() +def _resolve_target_user_id( + current_user: CurrentUser, + user_id: UUID | None, +) -> UUID: + if user_id is None: + return current_user.user_id + if current_user.role is not Role.ADMIN: + raise HTTPException(status_code=403, detail="Admin role required") + return user_id + + +def _shared_household_user_ids( + session: Session, current_user: CurrentUser +) -> set[UUID]: + membership = current_user.household_membership + if membership is None: + return set() + user_ids = session.exec( + select(HouseholdMembership.user_id).where( + HouseholdMembership.household_id == membership.household_id + ) + ).all() + return {uid for uid in user_ids if uid != current_user.user_id} + + +def _get_owned_or_admin_override( + session: Session, + model: type[Routine] | type[RoutineStep] | type[GroomingSchedule], + record_id: UUID, + current_user: CurrentUser, + user_id: UUID | None, +): + if user_id is None: + return get_owned_or_404(session, model, record_id, current_user) + target_user_id = _resolve_target_user_id(current_user, user_id) + record = session.get(model, record_id) + if record is None or record.user_id != target_user_id: + raise HTTPException(status_code=404, detail=f"{model.__name__} not found") + return record + + # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- @@ -289,6 +336,7 @@ def _ev(v: object) -> str: def _get_recent_skin_snapshot( session: Session, + target_user_id: UUID, reference_date: date, window_days: int = HISTORY_WINDOW_DAYS, fallback_days: int = SNAPSHOT_FALLBACK_DAYS, @@ -298,6 +346,7 @@ def _get_recent_skin_snapshot( snapshot = session.exec( select(SkinConditionSnapshot) + .where(SkinConditionSnapshot.user_id == target_user_id) .where(SkinConditionSnapshot.snapshot_date <= reference_date) .where(SkinConditionSnapshot.snapshot_date >= window_cutoff) .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) @@ -307,6 +356,7 @@ def _get_recent_skin_snapshot( return session.exec( select(SkinConditionSnapshot) + .where(SkinConditionSnapshot.user_id == target_user_id) .where(SkinConditionSnapshot.snapshot_date <= reference_date) .where(SkinConditionSnapshot.snapshot_date >= fallback_cutoff) .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) @@ -315,12 +365,14 @@ def _get_recent_skin_snapshot( def _get_latest_skin_snapshot_within_days( session: Session, + target_user_id: UUID, reference_date: date, max_age_days: int = SNAPSHOT_FALLBACK_DAYS, ) -> SkinConditionSnapshot | None: cutoff = reference_date - timedelta(days=max_age_days) return session.exec( select(SkinConditionSnapshot) + .where(SkinConditionSnapshot.user_id == target_user_id) .where(SkinConditionSnapshot.snapshot_date <= reference_date) .where(SkinConditionSnapshot.snapshot_date >= cutoff) .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) @@ -329,12 +381,14 @@ def _get_latest_skin_snapshot_within_days( def _build_skin_context( session: Session, + target_user_id: UUID, reference_date: date, window_days: int = HISTORY_WINDOW_DAYS, fallback_days: int = SNAPSHOT_FALLBACK_DAYS, ) -> str: snapshot = _get_recent_skin_snapshot( session, + target_user_id=target_user_id, reference_date=reference_date, window_days=window_days, fallback_days=fallback_days, @@ -354,10 +408,14 @@ def _build_skin_context( def _build_grooming_context( - session: Session, weekdays: Optional[list[int]] = None + session: Session, + target_user_id: UUID, + weekdays: Optional[list[int]] = None, ) -> str: entries = session.exec( - select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week)) + select(GroomingSchedule) + .where(GroomingSchedule.user_id == target_user_id) + .order_by(col(GroomingSchedule.day_of_week)) ).all() if not entries: return "GROOMING SCHEDULE: none\n" @@ -378,11 +436,14 @@ def _build_grooming_context( def _build_upcoming_grooming_context( session: Session, + target_user_id: UUID, start_date: date, days: int = 7, ) -> str: entries = session.exec( - select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week)) + select(GroomingSchedule) + .where(GroomingSchedule.user_id == target_user_id) + .order_by(col(GroomingSchedule.day_of_week)) ).all() if not entries: return f"UPCOMING GROOMING (next {days} days): none\n" @@ -420,12 +481,14 @@ def _build_upcoming_grooming_context( def _build_recent_history( session: Session, + target_user_id: UUID, reference_date: date, window_days: int = HISTORY_WINDOW_DAYS, ) -> str: cutoff = reference_date - timedelta(days=window_days) routines = session.exec( select(Routine) + .where(Routine.user_id == target_user_id) .where(Routine.routine_date <= reference_date) .where(Routine.routine_date >= cutoff) .order_by(col(Routine.routine_date).desc()) @@ -437,6 +500,7 @@ def _build_recent_history( steps = session.exec( select(RoutineStep) .where(RoutineStep.routine_id == r.id) + .where(RoutineStep.user_id == target_user_id) .order_by(col(RoutineStep.order_index)) ).all() step_names = [] @@ -458,11 +522,37 @@ def _build_recent_history( def _get_available_products( session: Session, + current_user: CurrentUser, time_filter: Optional[str] = None, include_minoxidil: bool = True, ) -> list[Product]: stmt = select(Product).where(col(Product.is_tool).is_(False)) - products = session.exec(stmt).all() + if current_user.role is not Role.ADMIN: + owned_products = session.exec( + stmt.where(col(Product.user_id) == current_user.user_id) + ).all() + shared_user_ids = _shared_household_user_ids(session, current_user) + shared_product_ids = ( + session.exec( + select(ProductInventory.product_id) + .where(col(ProductInventory.is_household_shared).is_(True)) + .where(col(ProductInventory.user_id).in_(list(shared_user_ids))) + .distinct() + ).all() + if shared_user_ids + else [] + ) + shared_products = ( + session.exec(stmt.where(col(Product.id).in_(shared_product_ids))).all() + if shared_product_ids + else [] + ) + products_by_id = {p.id: p for p in owned_products} + for product in shared_products: + products_by_id.setdefault(product.id, product) + products = list(products_by_id.values()) + else: + products = session.exec(stmt).all() result: list[Product] = [] for p in products: if p.is_medication and not _is_minoxidil_product(p): @@ -517,7 +607,9 @@ def _extract_requested_product_ids( def _get_products_with_inventory( - session: Session, product_ids: list[UUID] + session: Session, + current_user: CurrentUser, + product_ids: list[UUID], ) -> set[UUID]: """ Return set of product IDs that have active (non-finished) inventory. @@ -527,17 +619,33 @@ def _get_products_with_inventory( if not product_ids: return set() - inventory_rows = session.exec( + stmt = ( select(ProductInventory.product_id) .where(col(ProductInventory.product_id).in_(product_ids)) .where(col(ProductInventory.finished_at).is_(None)) - .distinct() - ).all() - + ) + if current_user.role is not Role.ADMIN: + owned_inventory_rows = session.exec( + stmt.where(col(ProductInventory.user_id) == current_user.user_id).distinct() + ).all() + shared_user_ids = _shared_household_user_ids(session, current_user) + shared_inventory_rows = session.exec( + stmt.where(col(ProductInventory.is_household_shared).is_(True)) + .where(col(ProductInventory.user_id).in_(list(shared_user_ids))) + .distinct() + ).all() + inventory_rows = set(owned_inventory_rows) + inventory_rows.update(shared_inventory_rows) + return inventory_rows + inventory_rows = session.exec(stmt.distinct()).all() return set(inventory_rows) -def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None: +def _expand_product_id( + session: Session, + current_user: CurrentUser, + short_or_full_id: str, +) -> UUID | None: """ Expand 8-char short_id to full UUID, or validate full UUID. @@ -558,7 +666,13 @@ def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None: uuid_obj = UUID(short_or_full_id) # Verify it exists product = session.get(Product, uuid_obj) - return uuid_obj if product else None + if product is None: + return None + return ( + uuid_obj + if is_product_visible(session, uuid_obj, current_user) + else None + ) except (ValueError, TypeError): return None @@ -567,7 +681,13 @@ def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None: product = session.exec( select(Product).where(Product.short_id == short_or_full_id) ).first() - return product.id if product else None + if product is None: + return None + return ( + product.id + if is_product_visible(session, product.id, current_user) + else None + ) # Invalid length return None @@ -590,6 +710,17 @@ def _build_day_context(leaving_home: Optional[bool]) -> str: return f"DAY CONTEXT:\n Leaving home: {val}\n" +def _coerce_action_type(value: object) -> GroomingAction | None: + if isinstance(value, GroomingAction): + return value + if isinstance(value, str): + try: + return GroomingAction(value) + except ValueError: + return None + return None + + _ROUTINES_SYSTEM_PROMPT = """\ Jesteś ekspertem planowania pielęgnacji. @@ -676,9 +807,12 @@ def list_routines( from_date: Optional[date] = None, to_date: Optional[date] = None, part_of_day: Optional[PartOfDay] = None, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - stmt = select(Routine) + target_user_id = _resolve_target_user_id(current_user, user_id) + stmt = select(Routine).where(Routine.user_id == target_user_id) if from_date is not None: stmt = stmt.where(Routine.routine_date >= from_date) if to_date is not None: @@ -688,10 +822,12 @@ def list_routines( routines = session.exec(stmt).all() routine_ids = [r.id for r in routines] - steps_by_routine: dict = {} + steps_by_routine: dict[UUID, list[RoutineStep]] = {} if routine_ids: all_steps = session.exec( - select(RoutineStep).where(col(RoutineStep.routine_id).in_(routine_ids)) + select(RoutineStep) + .where(col(RoutineStep.routine_id).in_(routine_ids)) + .where(RoutineStep.user_id == target_user_id) ).all() for step in all_steps: steps_by_routine.setdefault(step.routine_id, []).append(step) @@ -707,8 +843,14 @@ def list_routines( @router.post("", response_model=Routine, status_code=201) -def create_routine(data: RoutineCreate, session: Session = Depends(get_session)): - routine = Routine(id=uuid4(), **data.model_dump()) +def create_routine( + data: RoutineCreate, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + routine = Routine(id=uuid4(), user_id=target_user_id, **data.model_dump()) session.add(routine) session.commit() session.refresh(routine) @@ -724,19 +866,35 @@ def create_routine(data: RoutineCreate, session: Session = Depends(get_session)) def suggest_routine( data: SuggestRoutineRequest, session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): + target_user_id = current_user.user_id weekday = data.routine_date.weekday() - skin_ctx = _build_skin_context(session, reference_date=data.routine_date) - profile_ctx = build_user_profile_context(session, reference_date=data.routine_date) + skin_ctx = _build_skin_context( + session, + target_user_id=target_user_id, + reference_date=data.routine_date, + ) + profile_ctx = build_user_profile_context( + session, + reference_date=data.routine_date, + current_user=current_user, + ) upcoming_grooming_ctx = _build_upcoming_grooming_context( session, + target_user_id=target_user_id, start_date=data.routine_date, days=7, ) - history_ctx = _build_recent_history(session, reference_date=data.routine_date) + history_ctx = _build_recent_history( + session, + target_user_id=target_user_id, + reference_date=data.routine_date, + ) day_ctx = _build_day_context(data.leaving_home) available_products = _get_available_products( session, + current_user=current_user, time_filter=data.part_of_day.value, include_minoxidil=data.include_minoxidil_beard, ) @@ -752,7 +910,9 @@ def suggest_routine( # Phase 2: Use tiered context (summary mode for initial prompt) products_with_inventory = _get_products_with_inventory( - session, [p.id for p in available_products] + session, + current_user, + [p.id for p in available_products], ) products_ctx = build_products_context_summary_list( available_products, products_with_inventory @@ -865,22 +1025,35 @@ def suggest_routine( # Translation layer: Expand short_ids (8 chars) to full UUIDs (36 chars) steps = [] - for s in parsed.get("steps", []): + raw_steps = parsed.get("steps", []) + if not isinstance(raw_steps, list): + raw_steps = [] + for s in raw_steps: + if not isinstance(s, dict): + continue product_id_str = s.get("product_id") product_id_uuid = None - if product_id_str: + if isinstance(product_id_str, str) and product_id_str: # Expand short_id or validate full UUID - product_id_uuid = _expand_product_id(session, product_id_str) + product_id_uuid = _expand_product_id(session, current_user, product_id_str) + + action_type = s.get("action_type") + action_notes = s.get("action_notes") + region = s.get("region") + why_this_step = s.get("why_this_step") + optional = s.get("optional") steps.append( SuggestedStep( product_id=product_id_uuid, - action_type=s.get("action_type") or None, - action_notes=s.get("action_notes"), - region=s.get("region"), - why_this_step=s.get("why_this_step"), - optional=s.get("optional"), + action_type=_coerce_action_type(action_type), + action_notes=action_notes if isinstance(action_notes, str) else None, + region=region if isinstance(region, str) else None, + why_this_step=( + why_this_step if isinstance(why_this_step, str) else None + ), + optional=optional if isinstance(optional, bool) else None, ) ) @@ -904,6 +1077,7 @@ def suggest_routine( # Get skin snapshot for barrier state skin_snapshot = _get_latest_skin_snapshot_within_days( session, + target_user_id=target_user_id, reference_date=data.routine_date, ) @@ -964,7 +1138,9 @@ def suggest_routine( def suggest_batch( data: SuggestBatchRequest, session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): + target_user_id = current_user.user_id delta = (data.to_date - data.from_date).days + 1 if delta > 14: raise HTTPException( @@ -976,18 +1152,37 @@ def suggest_batch( weekdays = list( {(data.from_date + timedelta(days=i)).weekday() for i in range(delta)} ) - profile_ctx = build_user_profile_context(session, reference_date=data.from_date) - skin_ctx = _build_skin_context(session, reference_date=data.from_date) - grooming_ctx = _build_grooming_context(session, weekdays=weekdays) - history_ctx = _build_recent_history(session, reference_date=data.from_date) + profile_ctx = build_user_profile_context( + session, + reference_date=data.from_date, + current_user=current_user, + ) + skin_ctx = _build_skin_context( + session, + target_user_id=target_user_id, + reference_date=data.from_date, + ) + grooming_ctx = _build_grooming_context( + session, + target_user_id=target_user_id, + weekdays=weekdays, + ) + history_ctx = _build_recent_history( + session, + target_user_id=target_user_id, + reference_date=data.from_date, + ) batch_products = _get_available_products( session, + current_user=current_user, include_minoxidil=data.include_minoxidil_beard, ) # Phase 2: Use tiered context (summary mode for batch planning) products_with_inventory = _get_products_with_inventory( - session, [p.id for p in batch_products] + session, + current_user, + [p.id for p in batch_products], ) products_ctx = build_products_context_summary_list( batch_products, products_with_inventory @@ -1045,25 +1240,39 @@ def suggest_batch( except json.JSONDecodeError as e: raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") - def _parse_steps(raw_steps: list) -> list[SuggestedStep]: + def _parse_steps(raw_steps: list[dict[str, object]]) -> list[SuggestedStep]: """Parse steps and expand short_ids to full UUIDs.""" result = [] for s in raw_steps: product_id_str = s.get("product_id") product_id_uuid = None - if product_id_str: + if isinstance(product_id_str, str) and product_id_str: # Translation layer: expand short_id to full UUID - product_id_uuid = _expand_product_id(session, product_id_str) + product_id_uuid = _expand_product_id( + session, + current_user, + product_id_str, + ) + + action_type = s.get("action_type") + action_notes = s.get("action_notes") + region = s.get("region") + why_this_step = s.get("why_this_step") + optional = s.get("optional") result.append( SuggestedStep( product_id=product_id_uuid, - action_type=s.get("action_type") or None, - action_notes=s.get("action_notes"), - region=s.get("region"), - why_this_step=s.get("why_this_step"), - optional=s.get("optional"), + action_type=_coerce_action_type(action_type), + action_notes=( + action_notes if isinstance(action_notes, str) else None + ), + region=region if isinstance(region, str) else None, + why_this_step=( + why_this_step if isinstance(why_this_step, str) else None + ), + optional=optional if isinstance(optional, bool) else None, ) ) return result @@ -1086,6 +1295,7 @@ def suggest_batch( # Get skin snapshot for barrier state skin_snapshot = _get_latest_skin_snapshot_within_days( session, + target_user_id=target_user_id, reference_date=data.from_date, ) @@ -1140,15 +1350,36 @@ def suggest_batch( # Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed @router.get("/grooming-schedule", response_model=list[GroomingSchedule]) -def list_grooming_schedule(session: Session = Depends(get_session)): - return session.exec(select(GroomingSchedule)).all() +def list_grooming_schedule( + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + return session.exec( + select(GroomingSchedule).where(GroomingSchedule.user_id == target_user_id) + ).all() @router.get("/{routine_id}") -def get_routine(routine_id: UUID, session: Session = Depends(get_session)): - routine = get_or_404(session, Routine, routine_id) +def get_routine( + routine_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + routine = _get_owned_or_admin_override( + session, + Routine, + routine_id, + current_user, + user_id, + ) steps = session.exec( - select(RoutineStep).where(RoutineStep.routine_id == routine_id) + select(RoutineStep) + .where(RoutineStep.routine_id == routine_id) + .where(RoutineStep.user_id == target_user_id) ).all() data = routine.model_dump(mode="json") data["steps"] = [step.model_dump(mode="json") for step in steps] @@ -1159,9 +1390,17 @@ def get_routine(routine_id: UUID, session: Session = Depends(get_session)): def update_routine( routine_id: UUID, data: RoutineUpdate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - routine = get_or_404(session, Routine, routine_id) + routine = _get_owned_or_admin_override( + session, + Routine, + routine_id, + current_user, + user_id, + ) for key, value in data.model_dump(exclude_unset=True).items(): setattr(routine, key, value) session.add(routine) @@ -1171,8 +1410,19 @@ def update_routine( @router.delete("/{routine_id}", status_code=204) -def delete_routine(routine_id: UUID, session: Session = Depends(get_session)): - routine = get_or_404(session, Routine, routine_id) +def delete_routine( + routine_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + routine = _get_owned_or_admin_override( + session, + Routine, + routine_id, + current_user, + user_id, + ) session.delete(routine) session.commit() @@ -1186,10 +1436,28 @@ def delete_routine(routine_id: UUID, session: Session = Depends(get_session)): def add_step( routine_id: UUID, data: RoutineStepCreate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - get_or_404(session, Routine, routine_id) - step = RoutineStep(id=uuid4(), routine_id=routine_id, **data.model_dump()) + target_user_id = _resolve_target_user_id(current_user, user_id) + _ = _get_owned_or_admin_override( + session, + Routine, + routine_id, + current_user, + user_id, + ) + if data.product_id and not is_product_visible( + session, data.product_id, current_user + ): + raise HTTPException(status_code=404, detail="Product not found") + step = RoutineStep( + id=uuid4(), + user_id=target_user_id, + routine_id=routine_id, + **data.model_dump(), + ) session.add(step) session.commit() session.refresh(step) @@ -1200,9 +1468,21 @@ def add_step( def update_step( step_id: UUID, data: RoutineStepUpdate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - step = get_or_404(session, RoutineStep, step_id) + step = _get_owned_or_admin_override( + session, + RoutineStep, + step_id, + current_user, + user_id, + ) + if data.product_id and not is_product_visible( + session, data.product_id, current_user + ): + raise HTTPException(status_code=404, detail="Product not found") for key, value in data.model_dump(exclude_unset=True).items(): setattr(step, key, value) session.add(step) @@ -1212,8 +1492,19 @@ def update_step( @router.delete("/steps/{step_id}", status_code=204) -def delete_step(step_id: UUID, session: Session = Depends(get_session)): - step = get_or_404(session, RoutineStep, step_id) +def delete_step( + step_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + step = _get_owned_or_admin_override( + session, + RoutineStep, + step_id, + current_user, + user_id, + ) session.delete(step) session.commit() @@ -1225,9 +1516,13 @@ def delete_step(step_id: UUID, session: Session = Depends(get_session)): @router.post("/grooming-schedule", response_model=GroomingSchedule, status_code=201) def create_grooming_schedule( - data: GroomingScheduleCreate, session: Session = Depends(get_session) + data: GroomingScheduleCreate, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - entry = GroomingSchedule(id=uuid4(), **data.model_dump()) + target_user_id = _resolve_target_user_id(current_user, user_id) + entry = GroomingSchedule(id=uuid4(), user_id=target_user_id, **data.model_dump()) session.add(entry) session.commit() session.refresh(entry) @@ -1238,9 +1533,17 @@ def create_grooming_schedule( def update_grooming_schedule( entry_id: UUID, data: GroomingScheduleUpdate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - entry = get_or_404(session, GroomingSchedule, entry_id) + entry = _get_owned_or_admin_override( + session, + GroomingSchedule, + entry_id, + current_user, + user_id, + ) for key, value in data.model_dump(exclude_unset=True).items(): setattr(entry, key, value) session.add(entry) @@ -1250,7 +1553,18 @@ def update_grooming_schedule( @router.delete("/grooming-schedule/{entry_id}", status_code=204) -def delete_grooming_schedule(entry_id: UUID, session: Session = Depends(get_session)): - entry = get_or_404(session, GroomingSchedule, entry_id) +def delete_grooming_schedule( + entry_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + entry = _get_owned_or_admin_override( + session, + GroomingSchedule, + entry_id, + current_user, + user_id, + ) session.delete(entry) session.commit() diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 730db1e..4984bf9 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -4,15 +4,17 @@ from datetime import date from typing import Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from google.genai import types as genai_types from pydantic import BaseModel as PydanticBase from pydantic import ValidationError from sqlmodel import Session, SQLModel, select from db import get_session +from innercontext.api.auth_deps import get_current_user from innercontext.api.llm_context import build_user_profile_context -from innercontext.api.utils import get_or_404 +from innercontext.api.utils import get_owned_or_404 +from innercontext.auth import CurrentUser from innercontext.llm import call_gemini, get_extraction_config from innercontext.models import ( SkinConditionSnapshot, @@ -26,6 +28,7 @@ from innercontext.models.enums import ( SkinTexture, SkinType, ) +from innercontext.models.enums import Role from innercontext.validators import PhotoValidator logger = logging.getLogger(__name__) @@ -135,6 +138,34 @@ OUTPUT (all fields optional): # --------------------------------------------------------------------------- +def _resolve_target_user_id( + current_user: CurrentUser, + user_id: UUID | None, +) -> UUID: + if user_id is None: + return current_user.user_id + if current_user.role is not Role.ADMIN: + raise HTTPException(status_code=403, detail="Admin role required") + return user_id + + +def _get_owned_or_admin_override( + session: Session, + snapshot_id: UUID, + current_user: CurrentUser, + user_id: UUID | None, +) -> SkinConditionSnapshot: + if user_id is None: + return get_owned_or_404( + session, SkinConditionSnapshot, snapshot_id, current_user + ) + target_user_id = _resolve_target_user_id(current_user, user_id) + snapshot = session.get(SkinConditionSnapshot, snapshot_id) + if snapshot is None or snapshot.user_id != target_user_id: + raise HTTPException(status_code=404, detail="SkinConditionSnapshot not found") + return snapshot + + MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB @@ -142,6 +173,7 @@ MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB async def analyze_skin_photos( photos: list[UploadFile] = File(...), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ) -> SkinPhotoAnalysisResponse: if not (1 <= len(photos) <= 3): raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.") @@ -174,7 +206,11 @@ async def analyze_skin_photos( ) parts.append( genai_types.Part.from_text( - text=build_user_profile_context(session, reference_date=date.today()) + text=build_user_profile_context( + session, + reference_date=date.today(), + current_user=current_user, + ) ) ) @@ -224,9 +260,14 @@ def list_snapshots( from_date: Optional[date] = None, to_date: Optional[date] = None, overall_state: Optional[OverallSkinState] = None, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - stmt = select(SkinConditionSnapshot) + target_user_id = _resolve_target_user_id(current_user, user_id) + stmt = select(SkinConditionSnapshot).where( + SkinConditionSnapshot.user_id == target_user_id + ) if from_date is not None: stmt = stmt.where(SkinConditionSnapshot.snapshot_date >= from_date) if to_date is not None: @@ -237,8 +278,18 @@ def list_snapshots( @router.post("", response_model=SkinConditionSnapshotPublic, status_code=201) -def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session)): - snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump()) +def create_snapshot( + data: SnapshotCreate, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + target_user_id = _resolve_target_user_id(current_user, user_id) + snapshot = SkinConditionSnapshot( + id=uuid4(), + user_id=target_user_id, + **data.model_dump(), + ) session.add(snapshot) session.commit() session.refresh(snapshot) @@ -246,17 +297,34 @@ def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session @router.get("/{snapshot_id}", response_model=SkinConditionSnapshotPublic) -def get_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)): - return get_or_404(session, SkinConditionSnapshot, snapshot_id) +def get_snapshot( + snapshot_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + return _get_owned_or_admin_override( + session, + snapshot_id, + current_user, + user_id, + ) @router.patch("/{snapshot_id}", response_model=SkinConditionSnapshotPublic) def update_snapshot( snapshot_id: UUID, data: SnapshotUpdate, + user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), ): - snapshot = get_or_404(session, SkinConditionSnapshot, snapshot_id) + snapshot = _get_owned_or_admin_override( + session, + snapshot_id, + current_user, + user_id, + ) for key, value in data.model_dump(exclude_unset=True).items(): setattr(snapshot, key, value) session.add(snapshot) @@ -266,7 +334,17 @@ def update_snapshot( @router.delete("/{snapshot_id}", status_code=204) -def delete_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)): - snapshot = get_or_404(session, SkinConditionSnapshot, snapshot_id) +def delete_snapshot( + snapshot_id: UUID, + user_id: UUID | None = Query(default=None), + session: Session = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + snapshot = _get_owned_or_admin_override( + session, + snapshot_id, + current_user, + user_id, + ) session.delete(snapshot) session.commit() diff --git a/backend/innercontext/services/pricing_jobs.py b/backend/innercontext/services/pricing_jobs.py index 9e9c9dd..9d6a24e 100644 --- a/backend/innercontext/services/pricing_jobs.py +++ b/backend/innercontext/services/pricing_jobs.py @@ -1,4 +1,5 @@ from datetime import datetime +from uuid import UUID from sqlmodel import Session, col, select @@ -66,9 +67,53 @@ def _apply_pricing_snapshot(session: Session, computed_at: datetime) -> int: return len(products) +def _scope_user_id(scope: str) -> UUID | None: + prefix = "user:" + if not scope.startswith(prefix): + return None + raw_user_id = scope[len(prefix) :].strip() + if not raw_user_id: + return None + try: + return UUID(raw_user_id) + except ValueError: + return None + + +def _apply_pricing_snapshot_for_scope( + session: Session, + *, + computed_at: datetime, + scope: str, +) -> int: + from innercontext.api.products import _compute_pricing_outputs + + scoped_user_id = _scope_user_id(scope) + stmt = select(Product) + if scoped_user_id is not None: + stmt = stmt.where(Product.user_id == scoped_user_id) + products = list(session.exec(stmt).all()) + pricing_outputs = _compute_pricing_outputs(products) + + for product in products: + tier, price_per_use_pln, tier_source = pricing_outputs.get( + product.id, (None, None, None) + ) + product.price_tier = tier + product.price_per_use_pln = price_per_use_pln + product.price_tier_source = tier_source + product.pricing_computed_at = computed_at + + return len(products) + + def process_pricing_job(session: Session, job: PricingRecalcJob) -> int: try: - updated_count = _apply_pricing_snapshot(session, computed_at=utc_now()) + updated_count = _apply_pricing_snapshot_for_scope( + session, + computed_at=utc_now(), + scope=job.scope, + ) job.status = "succeeded" job.finished_at = utc_now() job.error = None diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e35dfba..b8831bb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -37,27 +37,32 @@ def session(monkeypatch): @pytest.fixture() -def client(session, monkeypatch): +def current_user() -> CurrentUser: + claims = TokenClaims( + issuer="https://auth.test", + subject="test-user", + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + groups=("innercontext-admin",), + raw_claims={"iss": "https://auth.test", "sub": "test-user"}, + ) + return CurrentUser( + user_id=uuid4(), + role=Role.ADMIN, + identity=IdentityData.from_claims(claims), + claims=claims, + ) + + +@pytest.fixture() +def client(session, monkeypatch, current_user): """TestClient using the per-test session for every request.""" def _override(): yield session def _current_user_override(): - claims = TokenClaims( - issuer="https://auth.test", - subject="test-user", - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - groups=("innercontext-admin",), - raw_claims={"iss": "https://auth.test", "sub": "test-user"}, - ) - return CurrentUser( - user_id=uuid4(), - role=Role.ADMIN, - identity=IdentityData.from_claims(claims), - claims=claims, - ) + return current_user app.dependency_overrides[get_session] = _override app.dependency_overrides[get_current_user] = _current_user_override diff --git a/backend/tests/test_ai_logs.py b/backend/tests/test_ai_logs.py index 47a168e..8fe2a60 100644 --- a/backend/tests/test_ai_logs.py +++ b/backend/tests/test_ai_logs.py @@ -4,12 +4,13 @@ from typing import Any, cast from innercontext.models.ai_log import AICallLog -def test_list_ai_logs_normalizes_tool_trace_string(client, session): +def test_list_ai_logs_normalizes_tool_trace_string(client, session, current_user): log = AICallLog( id=uuid.uuid4(), endpoint="routines/suggest", model="gemini-3-flash-preview", success=True, + user_id=current_user.user_id, ) log.tool_trace = cast( Any, @@ -26,12 +27,13 @@ def test_list_ai_logs_normalizes_tool_trace_string(client, session): assert data[0]["tool_trace"]["events"][0]["function"] == "get_product_inci" -def test_get_ai_log_normalizes_tool_trace_string(client, session): +def test_get_ai_log_normalizes_tool_trace_string(client, session, current_user): log = AICallLog( id=uuid.uuid4(), endpoint="routines/suggest", model="gemini-3-flash-preview", success=True, + user_id=current_user.user_id, ) log.tool_trace = cast(Any, '{"mode":"function_tools","round":1}') session.add(log) diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index dae411f..28c885f 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -3,7 +3,7 @@ from datetime import date from unittest.mock import patch from innercontext.models import Routine, SkinConditionSnapshot -from innercontext.models.enums import BarrierState, OverallSkinState +from innercontext.models.enums import BarrierState, OverallSkinState, PartOfDay # --------------------------------------------------------------------------- # Routines @@ -223,13 +223,14 @@ def test_delete_grooming_schedule_not_found(client): assert r.status_code == 404 -def test_suggest_routine(client, session): +def test_suggest_routine(client, session, current_user): with patch( "innercontext.api.routines.call_gemini_with_function_tools" ) as mock_gemini: session.add( SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=date(2026, 2, 22), overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -272,18 +273,20 @@ def test_suggest_routine(client, session): assert "get_product_details" in kwargs["function_handlers"] -def test_suggest_batch(client, session): +def test_suggest_batch(client, session, current_user): with patch("innercontext.api.routines.call_gemini") as mock_gemini: session.add( Routine( id=uuid.uuid4(), + user_id=current_user.user_id, routine_date=date(2026, 2, 27), - part_of_day="pm", + part_of_day=PartOfDay.PM, ) ) session.add( SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=date(2026, 2, 20), overall_state=OverallSkinState.GOOD, hydration_level=4, diff --git a/backend/tests/test_routines_auth.py b/backend/tests/test_routines_auth.py new file mode 100644 index 0000000..9696556 --- /dev/null +++ b/backend/tests/test_routines_auth.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import patch +from uuid import uuid4 + +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import CurrentUser, IdentityData, TokenClaims +from innercontext.models import Role +from main import app + + +def _user(subject: str, *, role: Role = Role.MEMBER) -> CurrentUser: + claims = TokenClaims( + issuer="https://auth.test", + subject=subject, + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + raw_claims={"iss": "https://auth.test", "sub": subject}, + ) + return CurrentUser( + user_id=uuid4(), + role=role, + identity=IdentityData.from_claims(claims), + claims=claims, + ) + + +def _set_current_user(user: CurrentUser) -> None: + app.dependency_overrides[get_current_user] = lambda: user + + +def test_suggest_uses_current_user_profile_and_visible_products_only(client): + owner = _user("owner") + other = _user("other") + + _set_current_user(owner) + owner_profile = client.patch( + "/profile", json={"birth_date": "1991-01-15", "sex_at_birth": "male"} + ) + owner_product = client.post( + "/products", + json={ + "name": "Owner Serum", + "brand": "Test", + "category": "serum", + "recommended_time": "both", + "leave_on": True, + }, + ) + assert owner_profile.status_code == 200 + assert owner_product.status_code == 201 + + _set_current_user(other) + other_profile = client.patch( + "/profile", json={"birth_date": "1975-06-20", "sex_at_birth": "female"} + ) + other_product = client.post( + "/products", + json={ + "name": "Other Serum", + "brand": "Test", + "category": "serum", + "recommended_time": "both", + "leave_on": True, + }, + ) + assert other_profile.status_code == 200 + assert other_product.status_code == 201 + + _set_current_user(owner) + + with patch( + "innercontext.api.routines.call_gemini_with_function_tools" + ) as mock_gemini: + mock_response = type( + "Response", + (), + { + "text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "ok", "summary": {"primary_goal": "safe", "constraints_applied": [], "confidence": 0.7}}' + }, + ) + mock_gemini.return_value = (mock_response, None) + + response = client.post( + "/routines/suggest", + json={ + "routine_date": "2026-03-05", + "part_of_day": "am", + "include_minoxidil_beard": False, + }, + ) + assert response.status_code == 200 + + kwargs = mock_gemini.call_args.kwargs + prompt = kwargs["contents"] + assert "Birth date: 1991-01-15" in prompt + assert "Birth date: 1975-06-20" not in prompt + assert "Owner Serum" in prompt + assert "Other Serum" not in prompt + + handler = kwargs["function_handlers"]["get_product_details"] + payload = handler( + { + "product_ids": [ + owner_product.json()["id"], + other_product.json()["id"], + ] + } + ) + assert len(payload["products"]) == 1 + assert payload["products"][0]["name"] == "Owner Serum" diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index b547f62..4041c22 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -78,17 +78,22 @@ def test_ev(): assert _ev("string") == "string" -def test_build_skin_context(session: Session): +def test_build_skin_context(session: Session, current_user): # Empty reference_date = date(2026, 3, 10) assert ( - _build_skin_context(session, reference_date=reference_date) + _build_skin_context( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) == "SKIN CONDITION: no data\n" ) # With data snap = SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=reference_date, overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -100,7 +105,11 @@ def test_build_skin_context(session: Session): session.add(snap) session.commit() - ctx = _build_skin_context(session, reference_date=reference_date) + ctx = _build_skin_context( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) assert "SKIN CONDITION (snapshot from" in ctx assert "Overall state: good" in ctx assert "Hydration: 4/5" in ctx @@ -112,10 +121,12 @@ def test_build_skin_context(session: Session): def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days( session: Session, + current_user, ): reference_date = date(2026, 3, 20) snap = SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=10), overall_state=OverallSkinState.FAIR, hydration_level=3, @@ -126,16 +137,23 @@ def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days( session.add(snap) session.commit() - ctx = _build_skin_context(session, reference_date=reference_date) + ctx = _build_skin_context( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) assert f"snapshot from {reference_date - timedelta(days=10)}" in ctx assert "Barrier: compromised" in ctx -def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session): +def test_build_skin_context_ignores_snapshot_older_than_14_days( + session: Session, current_user +): reference_date = date(2026, 3, 20) snap = SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=15), overall_state=OverallSkinState.FAIR, hydration_level=3, @@ -145,15 +163,20 @@ def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session session.commit() assert ( - _build_skin_context(session, reference_date=reference_date) + _build_skin_context( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) == "SKIN CONDITION: no data\n" ) -def test_get_recent_skin_snapshot_prefers_window_match(session: Session): +def test_get_recent_skin_snapshot_prefers_window_match(session: Session, current_user): reference_date = date(2026, 3, 20) older = SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=10), overall_state=OverallSkinState.POOR, hydration_level=2, @@ -161,6 +184,7 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session): ) newer = SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=2), overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -169,7 +193,11 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session): session.add_all([older, newer]) session.commit() - snapshot = _get_recent_skin_snapshot(session, reference_date=reference_date) + snapshot = _get_recent_skin_snapshot( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) assert snapshot is not None assert snapshot.id == newer.id @@ -177,10 +205,12 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session): def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( session: Session, + current_user, ): reference_date = date(2026, 3, 20) older = SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=10), overall_state=OverallSkinState.POOR, hydration_level=2, @@ -188,6 +218,7 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( ) newer = SkinConditionSnapshot( id=uuid.uuid4(), + user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=2), overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -198,6 +229,7 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( snapshot = _get_latest_skin_snapshot_within_days( session, + target_user_id=current_user.user_id, reference_date=reference_date, ) @@ -205,39 +237,65 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( assert snapshot.id == newer.id -def test_build_grooming_context(session: Session): - assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n" +def test_build_grooming_context(session: Session, current_user): + assert ( + _build_grooming_context(session, target_user_id=current_user.user_id) + == "GROOMING SCHEDULE: none\n" + ) sch = GroomingSchedule( - id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning" + id=uuid.uuid4(), + user_id=current_user.user_id, + day_of_week=0, + action="shaving_oneblade", + notes="Morning", ) session.add(sch) session.commit() - ctx = _build_grooming_context(session) + ctx = _build_grooming_context(session, target_user_id=current_user.user_id) assert "GROOMING SCHEDULE:" in ctx assert "poniedziałek: shaving_oneblade (Morning)" in ctx # Test weekdays filter - ctx2 = _build_grooming_context(session, weekdays=[1]) # not monday + ctx2 = _build_grooming_context( + session, + target_user_id=current_user.user_id, + weekdays=[1], + ) # not monday assert "(no entries for specified days)" in ctx2 -def test_build_upcoming_grooming_context(session: Session): +def test_build_upcoming_grooming_context(session: Session, current_user): assert ( - _build_upcoming_grooming_context(session, start_date=date(2026, 3, 2), days=7) + _build_upcoming_grooming_context( + session, + target_user_id=current_user.user_id, + start_date=date(2026, 3, 2), + days=7, + ) == "UPCOMING GROOMING (next 7 days): none\n" ) monday = GroomingSchedule( - id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning" + id=uuid.uuid4(), + user_id=current_user.user_id, + day_of_week=0, + action="shaving_oneblade", + notes="Morning", + ) + wednesday = GroomingSchedule( + id=uuid.uuid4(), + user_id=current_user.user_id, + day_of_week=2, + action="dermarolling", ) - wednesday = GroomingSchedule(id=uuid.uuid4(), day_of_week=2, action="dermarolling") session.add_all([monday, wednesday]) session.commit() ctx = _build_upcoming_grooming_context( session, + target_user_id=current_user.user_id, start_date=date(2026, 3, 2), days=7, ) @@ -246,14 +304,23 @@ def test_build_upcoming_grooming_context(session: Session): assert "za 2 dni (2026-03-04, środa): dermarolling" in ctx -def test_build_recent_history(session: Session): +def test_build_recent_history(session: Session, current_user): reference_date = date(2026, 3, 10) assert ( - _build_recent_history(session, reference_date=reference_date) + _build_recent_history( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) == "RECENT ROUTINES: none\n" ) - r = Routine(id=uuid.uuid4(), routine_date=reference_date, part_of_day="am") + r = Routine( + id=uuid.uuid4(), + user_id=current_user.user_id, + routine_date=reference_date, + part_of_day="am", + ) session.add(r) p = Product( id=uuid.uuid4(), @@ -268,19 +335,37 @@ def test_build_recent_history(session: Session): session.add(p) session.commit() - s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id) + s1 = RoutineStep( + id=uuid.uuid4(), + user_id=current_user.user_id, + routine_id=r.id, + order_index=1, + product_id=p.id, + ) s2 = RoutineStep( - id=uuid.uuid4(), routine_id=r.id, order_index=2, action_type="shaving_razor" + id=uuid.uuid4(), + user_id=current_user.user_id, + routine_id=r.id, + order_index=2, + action_type="shaving_razor", ) # Step with non-existent product s3 = RoutineStep( - id=uuid.uuid4(), routine_id=r.id, order_index=3, product_id=uuid.uuid4() + id=uuid.uuid4(), + user_id=current_user.user_id, + routine_id=r.id, + order_index=3, + product_id=uuid.uuid4(), ) session.add_all([s1, s2, s3]) session.commit() - ctx = _build_recent_history(session, reference_date=reference_date) + ctx = _build_recent_history( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) assert "RECENT ROUTINES:" in ctx assert "AM:" in ctx assert "cleanser [" in ctx @@ -288,31 +373,38 @@ def test_build_recent_history(session: Session): assert "unknown [" in ctx -def test_build_recent_history_uses_reference_window(session: Session): +def test_build_recent_history_uses_reference_window(session: Session, current_user): reference_date = date(2026, 3, 10) recent = Routine( id=uuid.uuid4(), + user_id=current_user.user_id, routine_date=reference_date - timedelta(days=3), part_of_day="pm", ) old = Routine( id=uuid.uuid4(), + user_id=current_user.user_id, routine_date=reference_date - timedelta(days=6), part_of_day="am", ) session.add_all([recent, old]) session.commit() - ctx = _build_recent_history(session, reference_date=reference_date) + ctx = _build_recent_history( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) assert str(recent.routine_date) in ctx assert str(old.routine_date) not in ctx -def test_build_recent_history_excludes_future_routines(session: Session): +def test_build_recent_history_excludes_future_routines(session: Session, current_user): reference_date = date(2026, 3, 10) future = Routine( id=uuid.uuid4(), + user_id=current_user.user_id, routine_date=reference_date + timedelta(days=1), part_of_day="am", ) @@ -320,12 +412,16 @@ def test_build_recent_history_excludes_future_routines(session: Session): session.commit() assert ( - _build_recent_history(session, reference_date=reference_date) + _build_recent_history( + session, + target_user_id=current_user.user_id, + reference_date=reference_date, + ) == "RECENT ROUTINES: none\n" ) -def test_build_products_context_summary_list(session: Session): +def test_build_products_context_summary_list(session: Session, current_user): p1 = Product( id=uuid.uuid4(), short_id=str(uuid.uuid4())[:8], @@ -336,6 +432,7 @@ def test_build_products_context_summary_list(session: Session): recommended_time="both", leave_on=True, product_effect_profile={}, + user_id=current_user.user_id, ) p2 = Product( id=uuid.uuid4(), @@ -350,11 +447,16 @@ def test_build_products_context_summary_list(session: Session): context_rules={"safe_after_shaving": False}, min_interval_hours=12, max_frequency_per_week=7, + user_id=current_user.user_id, ) session.add_all([p1, p2]) session.commit() - products_am = _get_available_products(session, time_filter="am") + products_am = _get_available_products( + session, + current_user=current_user, + time_filter="am", + ) ctx = build_products_context_summary_list(products_am, {p2.id}) assert "Regaine Minoxidil" in ctx @@ -375,7 +477,7 @@ def test_build_day_context(): assert "Leaving home: no" in _build_day_context(False) -def test_get_available_products_respects_filters(session: Session): +def test_get_available_products_respects_filters(session: Session, current_user): regular_med = Product( id=uuid.uuid4(), name="Tretinoin", @@ -385,6 +487,7 @@ def test_get_available_products_respects_filters(session: Session): recommended_time="pm", leave_on=True, product_effect_profile={}, + user_id=current_user.user_id, ) minoxidil_med = Product( id=uuid.uuid4(), @@ -395,6 +498,7 @@ def test_get_available_products_respects_filters(session: Session): recommended_time="both", leave_on=True, product_effect_profile={}, + user_id=current_user.user_id, ) am_product = Product( id=uuid.uuid4(), @@ -404,6 +508,7 @@ def test_get_available_products_respects_filters(session: Session): recommended_time="am", leave_on=True, product_effect_profile={}, + user_id=current_user.user_id, ) pm_product = Product( id=uuid.uuid4(), @@ -413,11 +518,16 @@ def test_get_available_products_respects_filters(session: Session): recommended_time="pm", leave_on=True, product_effect_profile={}, + user_id=current_user.user_id, ) session.add_all([regular_med, minoxidil_med, am_product, pm_product]) session.commit() - am_available = _get_available_products(session, time_filter="am") + am_available = _get_available_products( + session, + current_user=current_user, + time_filter="am", + ) am_names = {p.name for p in am_available} assert "Tretinoin" not in am_names assert "Minoxidil 5%" in am_names @@ -508,7 +618,10 @@ def test_extract_active_names_uses_compact_distinct_names(session: Session): assert names == ["Niacinamide", "Zinc PCA"] -def test_get_available_products_excludes_minoxidil_when_flag_false(session: Session): +def test_get_available_products_excludes_minoxidil_when_flag_false( + session: Session, + current_user, +): minoxidil = Product( id=uuid.uuid4(), name="Minoxidil 5%", @@ -518,6 +631,7 @@ def test_get_available_products_excludes_minoxidil_when_flag_false(session: Sess recommended_time="both", leave_on=True, product_effect_profile={}, + user_id=current_user.user_id, ) regular = Product( id=uuid.uuid4(), @@ -527,18 +641,27 @@ def test_get_available_products_excludes_minoxidil_when_flag_false(session: Sess recommended_time="both", leave_on=False, product_effect_profile={}, + user_id=current_user.user_id, ) session.add_all([minoxidil, regular]) session.commit() # With flag True (default) - minoxidil included - products = _get_available_products(session, include_minoxidil=True) + products = _get_available_products( + session, + current_user=current_user, + include_minoxidil=True, + ) names = {p.name for p in products} assert "Minoxidil 5%" in names assert "Cleanser" in names # With flag False - minoxidil excluded - products = _get_available_products(session, include_minoxidil=False) + products = _get_available_products( + session, + current_user=current_user, + include_minoxidil=False, + ) names = {p.name for p in products} assert "Minoxidil 5%" not in names assert "Cleanser" in names diff --git a/backend/tests/test_skincare.py b/backend/tests/test_skincare.py index b6ce4b0..ac62a99 100644 --- a/backend/tests/test_skincare.py +++ b/backend/tests/test_skincare.py @@ -140,7 +140,7 @@ def test_analyze_photos_includes_user_profile_context(client, monkeypatch): def _fake_call_gemini(**kwargs): captured.update(kwargs) - return _FakeResponse() + return _FakeResponse(), None monkeypatch.setattr(skincare_api, "call_gemini", _fake_call_gemini) diff --git a/backend/tests/test_tenancy_domains.py b/backend/tests/test_tenancy_domains.py new file mode 100644 index 0000000..bbe1ce9 --- /dev/null +++ b/backend/tests/test_tenancy_domains.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import CurrentUser, IdentityData, TokenClaims +from innercontext.models import Role +from innercontext.models.ai_log import AICallLog +from main import app + + +def _user(subject: str, *, role: Role = Role.MEMBER) -> CurrentUser: + claims = TokenClaims( + issuer="https://auth.test", + subject=subject, + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + raw_claims={"iss": "https://auth.test", "sub": subject}, + ) + return CurrentUser( + user_id=uuid4(), + role=role, + identity=IdentityData.from_claims(claims), + claims=claims, + ) + + +def _set_current_user(user: CurrentUser) -> None: + app.dependency_overrides[get_current_user] = lambda: user + + +def test_profile_health_routines_skincare_ai_logs_are_user_scoped_by_default( + client, session +): + owner = _user("owner") + intruder = _user("intruder") + + _set_current_user(owner) + profile = client.patch( + "/profile", json={"birth_date": "1991-01-15", "sex_at_birth": "male"} + ) + medication = client.post( + "/health/medications", json={"kind": "prescription", "product_name": "Owner Rx"} + ) + routine = client.post( + "/routines", json={"routine_date": "2026-03-01", "part_of_day": "am"} + ) + snapshot = client.post("/skincare", json={"snapshot_date": "2026-03-01"}) + log = AICallLog(endpoint="routines/suggest", model="gemini-3-flash-preview") + log.user_id = owner.user_id + session.add(log) + session.commit() + session.refresh(log) + + assert profile.status_code == 200 + assert medication.status_code == 201 + assert routine.status_code == 201 + assert snapshot.status_code == 201 + + medication_id = medication.json()["record_id"] + routine_id = routine.json()["id"] + snapshot_id = snapshot.json()["id"] + + _set_current_user(intruder) + assert client.get("/profile").json() is None + assert client.get("/health/medications").json() == [] + assert client.get("/routines").json() == [] + assert client.get("/skincare").json() == [] + assert client.get("/ai-logs").json() == [] + + assert client.get(f"/health/medications/{medication_id}").status_code == 404 + assert client.get(f"/routines/{routine_id}").status_code == 404 + assert client.get(f"/skincare/{snapshot_id}").status_code == 404 + assert client.get(f"/ai-logs/{log.id}").status_code == 404 + + +def test_health_admin_override_requires_explicit_user_id(client): + owner = _user("owner") + admin = _user("admin", role=Role.ADMIN) + + _set_current_user(owner) + created = client.post( + "/health/lab-results", + json={ + "collected_at": "2026-03-01T00:00:00", + "test_code": "718-7", + "test_name_original": "Hemoglobin", + }, + ) + assert created.status_code == 201 + + _set_current_user(admin) + default_scope = client.get("/health/lab-results") + assert default_scope.status_code == 200 + assert default_scope.json()["items"] == [] + + overridden = client.get(f"/health/lab-results?user_id={owner.user_id}") + assert overridden.status_code == 200 + assert len(overridden.json()["items"]) == 1 From 4bfa4ea02d55a6d53205edf73b4155a8652f5fe8 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 15:55:32 +0100 Subject: [PATCH 06/10] chore(deploy): wire OIDC runtime configuration --- deploy.sh | 3 +- docs/DEPLOYMENT.md | 153 ++++++++++++------------------ nginx/innercontext.conf | 4 + scripts/healthcheck.sh | 22 ++++- scripts/validate-env.sh | 25 ++++- systemd/innercontext-node.service | 3 + systemd/innercontext.service | 5 + 7 files changed, 115 insertions(+), 100 deletions(-) diff --git a/deploy.sh b/deploy.sh index 39ef614..2ced385 100755 --- a/deploy.sh +++ b/deploy.sh @@ -346,7 +346,8 @@ check_backend_health() { check_frontend_health() { local i for ((i = 1; i <= 30; i++)); do - if remote "curl -sf http://127.0.0.1:3000/ >/dev/null"; then + # Allow 200 OK or 302/303/307 Redirect (to login) + if remote "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ | grep -qE '^(200|302|303|307)$'"; then log "Frontend health check passed" return 0 fi diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index ea1089c..eb14a1d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -94,117 +94,82 @@ chown -R innercontext:innercontext /opt/innercontext cat > /opt/innercontext/shared/backend/.env <<'EOF' DATABASE_URL=postgresql+psycopg://innercontext:change-me@/innercontext GEMINI_API_KEY=your-key + +# OIDC Configuration +OIDC_ISSUER=https://auth.example.com +OIDC_CLIENT_ID=innercontext-backend +OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration +OIDC_ADMIN_GROUPS=admins +OIDC_MEMBER_GROUPS=members + +# Bootstrap Admin (Optional, used for initial setup) +# BOOTSTRAP_ADMIN_OIDC_ISSUER=https://auth.example.com +# BOOTSTRAP_ADMIN_OIDC_SUB=user-sub-from-authelia +# BOOTSTRAP_ADMIN_EMAIL=admin@example.com +# BOOTSTRAP_ADMIN_NAME="Admin User" +# BOOTSTRAP_HOUSEHOLD_NAME="My Household" EOF cat > /opt/innercontext/shared/frontend/.env.production <<'EOF' PUBLIC_API_BASE=http://127.0.0.1:8000 ORIGIN=http://innercontext.lan + +# Session and OIDC +SESSION_SECRET=generate-a-long-random-string +OIDC_ISSUER=https://auth.example.com +OIDC_CLIENT_ID=innercontext-frontend +OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration EOF - -chmod 600 /opt/innercontext/shared/backend/.env -chmod 600 /opt/innercontext/shared/frontend/.env.production -chown innercontext:innercontext /opt/innercontext/shared/backend/.env -chown innercontext:innercontext /opt/innercontext/shared/frontend/.env.production ``` -### 4) Grant deploy sudo permissions +## OIDC Setup (Authelia) -```bash -cat > /etc/sudoers.d/innercontext-deploy << 'EOF' -innercontext ALL=(root) NOPASSWD: \ - /usr/bin/systemctl restart innercontext, \ - /usr/bin/systemctl restart innercontext-node, \ - /usr/bin/systemctl restart innercontext-pricing-worker, \ - /usr/bin/systemctl is-active innercontext, \ - /usr/bin/systemctl is-active innercontext-node, \ - /usr/bin/systemctl is-active innercontext-pricing-worker -EOF +This project uses OIDC for authentication. You need an OIDC provider like Authelia. -chmod 440 /etc/sudoers.d/innercontext-deploy -visudo -c -f /etc/sudoers.d/innercontext-deploy +### Authelia Client Configuration -# Must work without password or TTY prompt: -sudo -u innercontext sudo -n -l +Add the following to your Authelia `configuration.yml`: + +```yaml +identity_providers: + oidc: + clients: + - id: innercontext-frontend + description: InnerContext Frontend + secret: '$pbkdf2-sha512$...' # Not used for public client, but Authelia may require it + public: true + authorization_policy: one_factor + redirect_uris: + - http://innercontext.lan/auth/callback + scopes: + - openid + - profile + - email + - groups + userinfo_signed_response_alg: none + + - id: innercontext-backend + description: InnerContext Backend + secret: '$pbkdf2-sha512$...' + public: false + authorization_policy: one_factor + redirect_uris: [] + scopes: + - openid + - profile + - email + - groups + userinfo_signed_response_alg: none ``` -If `sudo -n -l` fails, deployments will fail during restart/rollback with: -`sudo: a terminal is required` or `sudo: a password is required`. +### Bootstrap Admin -### 5) Install systemd and nginx configs - -After first deploy (or after copying repo content to `/opt/innercontext/current`), install configs: - -```bash -cp /opt/innercontext/current/systemd/innercontext.service /etc/systemd/system/ -cp /opt/innercontext/current/systemd/innercontext-node.service /etc/systemd/system/ -cp /opt/innercontext/current/systemd/innercontext-pricing-worker.service /etc/systemd/system/ -systemctl daemon-reload -systemctl enable innercontext -systemctl enable innercontext-node -systemctl enable innercontext-pricing-worker - -cp /opt/innercontext/current/nginx/innercontext.conf /etc/nginx/sites-available/innercontext -ln -sf /etc/nginx/sites-available/innercontext /etc/nginx/sites-enabled/innercontext -rm -f /etc/nginx/sites-enabled/default -nginx -t && systemctl reload nginx -``` - -## Local Machine Setup - -`~/.ssh/config`: - -``` -Host innercontext - HostName - User innercontext -``` - -Ensure your public key is in `/home/innercontext/.ssh/authorized_keys`. - -## Deploy Commands - -From repository root on external machine: - -```bash -./deploy.sh # full deploy (default = all) -./deploy.sh all -./deploy.sh backend -./deploy.sh frontend -./deploy.sh list -./deploy.sh rollback -``` - -Optional overrides: - -```bash -DEPLOY_SERVER=innercontext ./deploy.sh all -DEPLOY_ROOT=/opt/innercontext ./deploy.sh backend -DEPLOY_ALLOW_DIRTY=1 ./deploy.sh frontend -``` - -## What `deploy.sh` Does - -For `backend` / `frontend` / `all`: - -1. Local checks (strict, fail-fast) -2. Acquire `/opt/innercontext/.deploy.lock` -3. Create `` release directory -4. Upload selected component(s) -5. Link shared env files in the release directory -6. `uv sync` + `alembic upgrade head` (backend scope) -7. Upload `scripts/`, `systemd/`, `nginx/` -8. Switch `current` to the prepared release -9. Restart affected services -10. Run health checks -11. Remove old releases (keep last 5) -12. Write deploy entry to `/opt/innercontext/deploy.log` - -If anything fails after promotion, script auto-rolls back to previous release. +To create the first user and household, set the `BOOTSTRAP_ADMIN_*` environment variables in the backend `.env` file and restart the backend. The backend will automatically create the user and household on startup if they don't exist. After the first successful login, you can remove these variables. ## Health Checks -- Backend: `http://127.0.0.1:8000/health-check` -- Frontend: `http://127.0.0.1:3000/` +- Backend: `http://127.0.0.1:8000/health-check` (returns 200) +- Frontend: `http://127.0.0.1:3000/` (returns 200 or 302 redirect to login) - Worker: `systemctl is-active innercontext-pricing-worker` Manual checks: diff --git a/nginx/innercontext.conf b/nginx/innercontext.conf index d75cb5d..5e9e68e 100644 --- a/nginx/innercontext.conf +++ b/nginx/innercontext.conf @@ -10,6 +10,8 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; } # SvelteKit Node server @@ -19,5 +21,7 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; } } diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index 9d370ff..21ddca0 100755 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -25,13 +25,27 @@ log() { check_service() { local service_name=$1 local url=$2 + local allow_redirect=${3:-false} if systemctl is-active --quiet "$service_name"; then - if curl -sf --max-time "$TIMEOUT" "$url" > /dev/null 2>&1; then + local curl_opts="-s --max-time $TIMEOUT" + if [ "$allow_redirect" = false ]; then + curl_opts="$curl_opts -f" + fi + + if curl $curl_opts "$url" > /dev/null 2>&1; then log "${GREEN}✓${NC} $service_name is healthy" return 0 else - log "${YELLOW}⚠${NC} $service_name is running but not responding at $url" + # If allow_redirect is true, we check if it's a 302 + if [ "$allow_redirect" = true ]; then + local status=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$url") + if [ "$status" = "302" ] || [ "$status" = "303" ] || [ "$status" = "307" ] || [ "$status" = "200" ]; then + log "${GREEN}✓${NC} $service_name is healthy (status $status)" + return 0 + fi + fi + log "${YELLOW}⚠${NC} $service_name is running but not responding correctly at $url" return 1 fi else @@ -45,8 +59,10 @@ backend_ok=0 frontend_ok=0 worker_ok=0 +# Backend health-check is public and should return 200 check_service "innercontext" "$BACKEND_URL" || backend_ok=1 -check_service "innercontext-node" "$FRONTEND_URL" || frontend_ok=1 +# Frontend root may redirect to login (302) +check_service "innercontext-node" "$FRONTEND_URL" true || frontend_ok=1 # Worker doesn't have HTTP endpoint, just check if it's running if systemctl is-active --quiet "innercontext-pricing-worker"; then diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh index 3ca90a4..d442fc6 100755 --- a/scripts/validate-env.sh +++ b/scripts/validate-env.sh @@ -25,7 +25,7 @@ warnings=0 log_error() { echo -e "${RED}✗${NC} $1" - ((errors++)) + errors=$((errors + 1)) } log_success() { @@ -34,7 +34,7 @@ log_success() { log_warning() { echo -e "${YELLOW}⚠${NC} $1" - ((warnings++)) + warnings=$((warnings + 1)) } check_symlink() { @@ -131,6 +131,21 @@ if [ -f "$SHARED_BACKEND_ENV" ]; then check_var "$SHARED_BACKEND_ENV" "GEMINI_API_KEY" check_var "$SHARED_BACKEND_ENV" "LOG_LEVEL" true check_var "$SHARED_BACKEND_ENV" "CORS_ORIGINS" true + + # OIDC Configuration + check_var "$SHARED_BACKEND_ENV" "OIDC_ISSUER" + check_var "$SHARED_BACKEND_ENV" "OIDC_CLIENT_ID" + check_var "$SHARED_BACKEND_ENV" "OIDC_DISCOVERY_URL" + check_var "$SHARED_BACKEND_ENV" "OIDC_ADMIN_GROUPS" + check_var "$SHARED_BACKEND_ENV" "OIDC_MEMBER_GROUPS" + check_var "$SHARED_BACKEND_ENV" "OIDC_JWKS_CACHE_TTL_SECONDS" true + + # Bootstrap Admin (Optional, used for initial setup) + check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_OIDC_ISSUER" true + check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_OIDC_SUB" true + check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_EMAIL" true + check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_NAME" true + check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_HOUSEHOLD_NAME" true fi echo "" @@ -138,6 +153,12 @@ echo "=== Validating Frontend Environment Variables ===" if [ -f "$SHARED_FRONTEND_ENV" ]; then check_var "$SHARED_FRONTEND_ENV" "PUBLIC_API_BASE" check_var "$SHARED_FRONTEND_ENV" "ORIGIN" + + # Session and OIDC + check_var "$SHARED_FRONTEND_ENV" "SESSION_SECRET" + check_var "$SHARED_FRONTEND_ENV" "OIDC_ISSUER" + check_var "$SHARED_FRONTEND_ENV" "OIDC_CLIENT_ID" + check_var "$SHARED_FRONTEND_ENV" "OIDC_DISCOVERY_URL" fi echo "" diff --git a/systemd/innercontext-node.service b/systemd/innercontext-node.service index a052333..2293910 100644 --- a/systemd/innercontext-node.service +++ b/systemd/innercontext-node.service @@ -1,5 +1,8 @@ [Unit] Description=innercontext SvelteKit Node frontend +# Required env vars in .env.production: +# PUBLIC_API_BASE, ORIGIN, SESSION_SECRET +# OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_DISCOVERY_URL After=network.target [Service] diff --git a/systemd/innercontext.service b/systemd/innercontext.service index 89545cb..d27138e 100644 --- a/systemd/innercontext.service +++ b/systemd/innercontext.service @@ -1,5 +1,10 @@ [Unit] Description=innercontext FastAPI backend +# Required env vars in .env: +# DATABASE_URL, GEMINI_API_KEY +# OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_DISCOVERY_URL +# OIDC_ADMIN_GROUPS, OIDC_MEMBER_GROUPS +# (Optional) BOOTSTRAP_ADMIN_OIDC_ISSUER, BOOTSTRAP_ADMIN_OIDC_SUB, etc. After=network.target [Service] From 1d5630ed8c715bba0b6ef2e819f70a87ca59f4e8 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 16:02:11 +0100 Subject: [PATCH 07/10] feat(api): add admin household management endpoints --- backend/innercontext/api/admin.py | 206 ++++++++++++++ backend/main.py | 10 +- backend/tests/test_admin_households.py | 354 +++++++++++++++++++++++++ 3 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 backend/innercontext/api/admin.py create mode 100644 backend/tests/test_admin_households.py diff --git a/backend/innercontext/api/admin.py b/backend/innercontext/api/admin.py new file mode 100644 index 0000000..0a5c1fd --- /dev/null +++ b/backend/innercontext/api/admin.py @@ -0,0 +1,206 @@ +from datetime import datetime +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlmodel import Session, SQLModel, select + +from db import get_session +from innercontext.api.auth_deps import require_admin +from innercontext.api.utils import get_or_404 +from innercontext.models import ( + Household, + HouseholdMembership, + HouseholdRole, + Role, + User, +) + +router = APIRouter(dependencies=[Depends(require_admin)]) +SessionDep = Annotated[Session, Depends(get_session)] + + +class AdminHouseholdPublic(SQLModel): + id: UUID + created_at: datetime + updated_at: datetime + + +class AdminHouseholdMembershipPublic(SQLModel): + id: UUID + user_id: UUID + household_id: UUID + role: HouseholdRole + created_at: datetime + updated_at: datetime + + +class AdminUserPublic(SQLModel): + id: UUID + oidc_issuer: str + oidc_subject: str + role: Role + created_at: datetime + updated_at: datetime + household_membership: AdminHouseholdMembershipPublic | None = None + + +class AdminHouseholdMembershipCreate(SQLModel): + user_id: UUID + role: HouseholdRole = HouseholdRole.MEMBER + + +def _membership_public( + membership: HouseholdMembership, +) -> AdminHouseholdMembershipPublic: + return AdminHouseholdMembershipPublic( + id=membership.id, + user_id=membership.user_id, + household_id=membership.household_id, + role=membership.role, + created_at=membership.created_at, + updated_at=membership.updated_at, + ) + + +def _household_public(household: Household) -> AdminHouseholdPublic: + return AdminHouseholdPublic( + id=household.id, + created_at=household.created_at, + updated_at=household.updated_at, + ) + + +def _user_public( + user: User, + membership: HouseholdMembership | None, +) -> AdminUserPublic: + return AdminUserPublic( + id=user.id, + oidc_issuer=user.oidc_issuer, + oidc_subject=user.oidc_subject, + role=user.role, + created_at=user.created_at, + updated_at=user.updated_at, + household_membership=( + _membership_public(membership) if membership is not None else None + ), + ) + + +def _get_membership_for_user( + session: Session, + user_id: UUID, +) -> HouseholdMembership | None: + return session.exec( + select(HouseholdMembership).where(HouseholdMembership.user_id == user_id) + ).first() + + +@router.get("/users", response_model=list[AdminUserPublic]) +def list_users(session: SessionDep): + users = sorted( + session.exec(select(User)).all(), + key=lambda user: (user.created_at, str(user.id)), + ) + memberships = session.exec(select(HouseholdMembership)).all() + memberships_by_user_id = { + membership.user_id: membership for membership in memberships + } + return [_user_public(user, memberships_by_user_id.get(user.id)) for user in users] + + +@router.post( + "/households", + response_model=AdminHouseholdPublic, + status_code=status.HTTP_201_CREATED, +) +def create_household(session: SessionDep): + household = Household() + session.add(household) + session.commit() + session.refresh(household) + return _household_public(household) + + +@router.post( + "/households/{household_id}/members", + response_model=AdminHouseholdMembershipPublic, + status_code=status.HTTP_201_CREATED, +) +def assign_household_member( + household_id: UUID, + payload: AdminHouseholdMembershipCreate, + session: SessionDep, +): + _ = get_or_404(session, Household, household_id) + _ = get_or_404(session, User, payload.user_id) + existing_membership = _get_membership_for_user(session, payload.user_id) + if existing_membership is not None: + detail = "User already belongs to a household" + if existing_membership.household_id == household_id: + detail = "User already belongs to this household" + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) + + membership = HouseholdMembership( + user_id=payload.user_id, + household_id=household_id, + role=payload.role, + ) + session.add(membership) + session.commit() + session.refresh(membership) + return _membership_public(membership) + + +@router.patch( + "/households/{household_id}/members/{user_id}", + response_model=AdminHouseholdMembershipPublic, +) +def move_household_member( + household_id: UUID, + user_id: UUID, + session: SessionDep, +): + _ = get_or_404(session, Household, household_id) + _ = get_or_404(session, User, user_id) + membership = _get_membership_for_user(session, user_id) + if membership is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="HouseholdMembership not found", + ) + if membership.household_id == household_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already belongs to this household", + ) + + membership.household_id = household_id + session.add(membership) + session.commit() + session.refresh(membership) + return _membership_public(membership) + + +@router.delete( + "/households/{household_id}/members/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def remove_household_member( + household_id: UUID, + user_id: UUID, + session: SessionDep, +): + _ = get_or_404(session, Household, household_id) + _ = get_or_404(session, User, user_id) + membership = _get_membership_for_user(session, user_id) + if membership is None or membership.household_id != household_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="HouseholdMembership not found", + ) + + session.delete(membership) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/main.py b/backend/main.py index 55280aa..3d74443 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,9 @@ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import AsyncIterator from dotenv import load_dotenv -load_dotenv() # load .env before db.py reads DATABASE_URL +_ = load_dotenv() # load .env before db.py reads DATABASE_URL from fastapi import Depends, FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 @@ -11,6 +11,7 @@ from sqlmodel import Session # noqa: E402 from db import create_db_and_tables, engine # noqa: E402 from innercontext.api import ( # noqa: E402 + admin, ai_logs, auth, health, @@ -25,11 +26,11 @@ from innercontext.services.pricing_jobs import enqueue_pricing_recalc # noqa: E @asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncIterator[None]: +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: create_db_and_tables() try: with Session(engine) as session: - enqueue_pricing_recalc(session) + _ = enqueue_pricing_recalc(session) session.commit() except Exception as exc: # pragma: no cover print(f"[startup] failed to enqueue pricing recalculation job: {exc}") @@ -52,6 +53,7 @@ app.add_middleware( protected = [Depends(get_current_user)] app.include_router(auth.router, prefix="/auth", tags=["auth"]) +app.include_router(admin.router, prefix="/admin", tags=["admin"]) app.include_router( products.router, prefix="/products", diff --git a/backend/tests/test_admin_households.py b/backend/tests/test_admin_households.py new file mode 100644 index 0000000..31b1f16 --- /dev/null +++ b/backend/tests/test_admin_households.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime, timedelta +from typing import cast +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import ( + CurrentHouseholdMembership, + CurrentUser, + IdentityData, + TokenClaims, +) +from innercontext.models import ( + Household, + HouseholdMembership, + HouseholdRole, + Role, + User, +) +from main import app + + +def _current_user( + user_id: UUID, + *, + role: Role = Role.ADMIN, + household_id: UUID | None = None, +) -> CurrentUser: + claims = TokenClaims( + issuer="https://auth.test", + subject=str(user_id), + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + groups=( + ("innercontext-admin",) if role is Role.ADMIN else ("innercontext-member",) + ), + raw_claims={"iss": "https://auth.test", "sub": str(user_id)}, + ) + membership = None + if household_id is not None: + membership = CurrentHouseholdMembership( + household_id=household_id, + role=HouseholdRole.MEMBER, + ) + return CurrentUser( + user_id=user_id, + role=role, + identity=IdentityData.from_claims(claims), + claims=claims, + household_membership=membership, + ) + + +def _create_user( + session: Session, + *, + role: Role = Role.MEMBER, + subject: str | None = None, +) -> User: + user = User( + oidc_issuer="https://auth.test", + oidc_subject=subject or str(uuid4()), + role=role, + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def _create_household(session: Session) -> Household: + household = Household() + session.add(household) + session.commit() + session.refresh(household) + return household + + +def _create_membership( + session: Session, + *, + user_id: UUID, + household_id: UUID, + role: HouseholdRole = HouseholdRole.MEMBER, +) -> HouseholdMembership: + membership = HouseholdMembership( + user_id=user_id, + household_id=household_id, + role=role, + ) + session.add(membership) + session.commit() + session.refresh(membership) + return membership + + +@pytest.fixture() +def auth_client( + session: Session, +) -> Generator[tuple[TestClient, dict[str, CurrentUser]], None, None]: + auth_state = {"current_user": _current_user(uuid4(), role=Role.ADMIN)} + + def _session_override(): + yield session + + def _current_user_override(): + return auth_state["current_user"] + + app.dependency_overrides[get_session] = _session_override + app.dependency_overrides[get_current_user] = _current_user_override + with TestClient(app) as client: + yield client, auth_state + app.dependency_overrides.clear() + + +def test_list_users_returns_local_users_with_memberships( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + unassigned_user = _create_user(session, subject="member-a") + assigned_user = _create_user(session, subject="member-b") + household = _create_household(session) + membership = _create_membership( + session, + user_id=assigned_user.id, + household_id=household.id, + role=HouseholdRole.OWNER, + ) + + response = client.get("/admin/users") + + assert response.status_code == 200 + response_users = cast(list[dict[str, object]], response.json()) + users = {item["id"]: item for item in response_users} + assert users[str(unassigned_user.id)]["household_membership"] is None + assert users[str(assigned_user.id)]["household_membership"] == { + "id": str(membership.id), + "user_id": str(assigned_user.id), + "household_id": str(household.id), + "role": "owner", + "created_at": membership.created_at.isoformat(), + "updated_at": membership.updated_at.isoformat(), + } + + +def test_create_household_returns_new_household( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + response = client.post("/admin/households") + + assert response.status_code == 201 + payload = cast(dict[str, object], response.json()) + household_id = UUID(cast(str, payload["id"])) + created = session.get(Household, household_id) + assert created is not None + + +def test_assign_member_creates_membership( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + + response = client.post( + f"/admin/households/{household.id}/members", + json={"user_id": str(user.id), "role": "owner"}, + ) + + assert response.status_code == 201 + payload = cast(dict[str, object], response.json()) + assert payload["user_id"] == str(user.id) + assert payload["household_id"] == str(household.id) + assert payload["role"] == "owner" + + membership = session.get(HouseholdMembership, UUID(cast(str, payload["id"]))) + assert membership is not None + assert membership.user_id == user.id + assert membership.household_id == household.id + assert membership.role is HouseholdRole.OWNER + + +def test_assign_member_rejects_already_assigned_user( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + current_household = _create_household(session) + target_household = _create_household(session) + _ = _create_membership(session, user_id=user.id, household_id=current_household.id) + + response = client.post( + f"/admin/households/{target_household.id}/members", + json={"user_id": str(user.id)}, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "User already belongs to a household" + + +def test_assign_member_rejects_unsynced_user( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + household = _create_household(session) + user_id = uuid4() + + response = client.post( + f"/admin/households/{household.id}/members", + json={"user_id": str(user_id)}, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_move_member_moves_user_between_households( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + source_household = _create_household(session) + target_household = _create_household(session) + membership = _create_membership( + session, + user_id=user.id, + household_id=source_household.id, + role=HouseholdRole.OWNER, + ) + + response = client.patch( + f"/admin/households/{target_household.id}/members/{user.id}" + ) + + assert response.status_code == 200 + payload = cast(dict[str, object], response.json()) + assert payload["id"] == str(membership.id) + assert payload["household_id"] == str(target_household.id) + assert payload["role"] == "owner" + + session.refresh(membership) + assert membership.household_id == target_household.id + + +def test_move_member_rejects_user_without_membership( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + target_household = _create_household(session) + + response = client.patch( + f"/admin/households/{target_household.id}/members/{user.id}" + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "HouseholdMembership not found" + + +def test_move_member_rejects_same_household_target( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + _ = _create_membership(session, user_id=user.id, household_id=household.id) + + response = client.patch(f"/admin/households/{household.id}/members/{user.id}") + + assert response.status_code == 409 + assert response.json()["detail"] == "User already belongs to this household" + + +def test_remove_membership_deletes_membership( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + membership = _create_membership(session, user_id=user.id, household_id=household.id) + + response = client.delete(f"/admin/households/{household.id}/members/{user.id}") + + assert response.status_code == 204 + assert session.get(HouseholdMembership, membership.id) is None + + +def test_remove_membership_requires_matching_household( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + other_household = _create_household(session) + _ = _create_membership(session, user_id=user.id, household_id=household.id) + + response = client.delete( + f"/admin/households/{other_household.id}/members/{user.id}" + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "HouseholdMembership not found" + + +@pytest.mark.parametrize( + ("method", "path", "json_body"), + [ + ("get", "/admin/users", None), + ("post", "/admin/households", None), + ("post", f"/admin/households/{uuid4()}/members", {"user_id": str(uuid4())}), + ("patch", f"/admin/households/{uuid4()}/members/{uuid4()}", None), + ("delete", f"/admin/households/{uuid4()}/members/{uuid4()}", None), + ], +) +def test_admin_household_routes_forbidden_for_member( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + method: str, + path: str, + json_body: dict[str, str] | None, +): + client, auth_state = auth_client + auth_state["current_user"] = _current_user(uuid4(), role=Role.MEMBER) + + response = client.request(method, path, json=json_body) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin role required" From b11f64d5a141cf86a17df2497a99a49ba1089bd7 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 16:27:24 +0100 Subject: [PATCH 08/10] refactor(frontend): route protected API access through server session --- .sisyphus/evidence/task-T8-protected-nav.md | 18 + .../evidence/task-T8-signed-out-network.txt | 10 + frontend/messages/en.json | 5 + frontend/messages/pl.json | 5 + frontend/src/lib/api.ts | 364 ++++++++++++------ .../src/lib/components/ProductForm.svelte | 20 +- frontend/src/lib/server/api.ts | 18 + frontend/src/routes/+layout.server.ts | 36 ++ frontend/src/routes/+layout.svelte | 130 ++++++- frontend/src/routes/+page.server.ts | 11 +- .../routes/api/products/parse-text/+server.ts | 23 ++ .../routes/api/routines/steps/[id]/+server.ts | 23 ++ .../routes/api/skin/analyze-photos/+server.ts | 19 + .../routes/health/lab-results/+page.server.ts | 19 +- .../health/lab-results/new/+page.server.ts | 7 +- .../routes/health/medications/+page.server.ts | 7 +- .../health/medications/new/+page.server.ts | 7 +- frontend/src/routes/products/+page.server.ts | 6 +- .../src/routes/products/[id]/+page.server.ts | 36 +- .../src/routes/products/new/+page.server.ts | 7 +- frontend/src/routes/profile/+page.server.ts | 26 +- frontend/src/routes/routines/+page.server.ts | 7 +- .../src/routes/routines/[id]/+page.server.ts | 27 +- .../src/routes/routines/[id]/+page.svelte | 22 +- .../grooming-schedule/+page.server.ts | 18 +- .../grooming-schedule/new/+page.server.ts | 7 +- .../src/routes/routines/new/+page.server.ts | 10 +- .../routes/routines/suggest/+page.server.ts | 40 +- frontend/src/routes/skin/+page.server.ts | 19 +- frontend/src/routes/skin/new/+page.server.ts | 7 +- frontend/src/routes/skin/new/+page.svelte | 22 +- 31 files changed, 727 insertions(+), 249 deletions(-) create mode 100644 .sisyphus/evidence/task-T8-protected-nav.md create mode 100644 .sisyphus/evidence/task-T8-signed-out-network.txt create mode 100644 frontend/src/lib/server/api.ts create mode 100644 frontend/src/routes/+layout.server.ts create mode 100644 frontend/src/routes/api/products/parse-text/+server.ts create mode 100644 frontend/src/routes/api/routines/steps/[id]/+server.ts create mode 100644 frontend/src/routes/api/skin/analyze-photos/+server.ts diff --git a/.sisyphus/evidence/task-T8-protected-nav.md b/.sisyphus/evidence/task-T8-protected-nav.md new file mode 100644 index 0000000..898bd0a --- /dev/null +++ b/.sisyphus/evidence/task-T8-protected-nav.md @@ -0,0 +1,18 @@ +# Task T8 Protected Navigation + +- QA app: `http://127.0.0.1:4175` +- Backend: `http://127.0.0.1:8002` +- Mock OIDC issuer: `http://127.0.0.1:9100` +- Backend DB: `.sisyphus/evidence/task-T8-qa.sqlite` + +Authenticated shell and protected route checks executed with Playwright: + +- `/` -> title `Dashboard - innercontext`, heading `Dashboard`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` +- `/products` -> title `Produkty — innercontext`, heading `Produkty`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` +- `/profile` -> title `Profil — innercontext`, heading `Profil`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` +- `/routines` -> title `Rutyny — innercontext`, heading `Rutyny`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` + +Logout endpoint check executed with Playwright request API: + +- `GET /auth/logout` -> `303` +- Location -> `http://127.0.0.1:9100/logout?client_id=innercontext-web&post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2F` diff --git a/.sisyphus/evidence/task-T8-signed-out-network.txt b/.sisyphus/evidence/task-T8-signed-out-network.txt new file mode 100644 index 0000000..9128fdf --- /dev/null +++ b/.sisyphus/evidence/task-T8-signed-out-network.txt @@ -0,0 +1,10 @@ +Playwright unauthenticated request check + +request: GET http://127.0.0.1:4175/products +cookies: none +maxRedirects: 0 + +status: 303 +location: /auth/login?returnTo=%2Fproducts + +result: protected page redirects to the login flow before returning page content. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 651301a..8eda3a9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -10,6 +10,11 @@ "nav_appName": "innercontext", "nav_appSubtitle": "personal health & skincare", + "auth_signedInAs": "Signed in as", + "auth_roleAdmin": "Admin", + "auth_roleMember": "Member", + "auth_logout": "Log out", + "common_save": "Save", "common_cancel": "Cancel", "common_add": "Add", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 33fbc31..c086a79 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -10,6 +10,11 @@ "nav_appName": "innercontext", "nav_appSubtitle": "zdrowie & pielęgnacja", + "auth_signedInAs": "Zalogowano jako", + "auth_roleAdmin": "Administrator", + "auth_roleMember": "Użytkownik", + "auth_logout": "Wyloguj", + "common_save": "Zapisz", "common_cancel": "Anuluj", "common_add": "Dodaj", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a0327f..f9bd236 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,43 +20,87 @@ import type { UserProfile, } from "./types"; -// ─── Core fetch helpers ────────────────────────────────────────────────────── +export interface ApiClientOptions { + fetch?: typeof globalThis.fetch; + accessToken?: string; +} -async function request(path: string, init: RequestInit = {}): Promise { - // Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000). - // Browser-side uses /api so nginx proxies the request on the correct host. - const base = browser ? "/api" : PUBLIC_API_BASE; - const url = `${base}${path}`; - const res = await fetch(url, { - headers: { "Content-Type": "application/json", ...init.headers }, +function resolveBase(options: ApiClientOptions): string { + if (browser && !options.accessToken) { + return "/api"; + } + + return PUBLIC_API_BASE; +} + +function buildHeaders( + initHeaders: HeadersInit | undefined, + body: BodyInit | null | undefined, + accessToken?: string, +): Headers { + const headers = new Headers(initHeaders); + + if (!(body instanceof FormData) && body !== undefined && body !== null) { + headers.set("Content-Type", headers.get("Content-Type") ?? "application/json"); + } + + if (accessToken && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + return headers; +} + +async function request( + path: string, + init: RequestInit = {}, + options: ApiClientOptions = {}, +): Promise { + const url = `${resolveBase(options)}${path}`; + const requestFn = options.fetch ?? fetch; + const res = await requestFn(url, { + headers: buildHeaders(init.headers, init.body, options.accessToken), ...init, }); + if (!res.ok) { const detail = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(detail?.detail ?? res.statusText); } + if (res.status === 204) return undefined as T; return res.json(); } -export const api = { - get: (path: string) => request(path), - post: (path: string, body: unknown) => - request(path, { method: "POST", body: JSON.stringify(body) }), - patch: (path: string, body: unknown) => - request(path, { method: "PATCH", body: JSON.stringify(body) }), - del: (path: string) => request(path, { method: "DELETE" }), -}; +export function createApiClient(options: ApiClientOptions = {}) { + return { + get: (path: string) => request(path, {}, options), + post: (path: string, body: unknown) => + request(path, { method: "POST", body: JSON.stringify(body) }, options), + postForm: (path: string, body: FormData) => + request(path, { method: "POST", body }, options), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }, options), + del: (path: string) => request(path, { method: "DELETE" }, options), + }; +} -// ─── Profile ───────────────────────────────────────────────────────────────── +export type ApiClient = ReturnType; -export const getProfile = (): Promise => api.get("/profile"); +export const api = createApiClient(); + +function resolveClient(options?: ApiClientOptions): ApiClient { + return options ? createApiClient(options) : api; +} + +export const getProfile = ( + options?: ApiClientOptions, +): Promise => resolveClient(options).get("/profile"); export const updateProfile = ( body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" }, -): Promise => api.patch("/profile", body); - -// ─── Products ──────────────────────────────────────────────────────────────── + options?: ApiClientOptions, +): Promise => resolveClient(options).patch("/profile", body); export interface ProductListParams { category?: string; @@ -68,62 +112,87 @@ export interface ProductListParams { export function getProducts( params: ProductListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.category) q.set("category", params.category); if (params.brand) q.set("brand", params.brand); if (params.targets) params.targets.forEach((t) => q.append("targets", t)); - if (params.is_medication != null) + if (params.is_medication != null) { q.set("is_medication", String(params.is_medication)); + } if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); const qs = q.toString(); - return api.get(`/products${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/products${qs ? `?${qs}` : ""}`); } export function getProductSummaries( params: ProductListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.category) q.set("category", params.category); if (params.brand) q.set("brand", params.brand); if (params.targets) params.targets.forEach((t) => q.append("targets", t)); - if (params.is_medication != null) + if (params.is_medication != null) { q.set("is_medication", String(params.is_medication)); + } if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); const qs = q.toString(); - return api.get(`/products/summary${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/products/summary${qs ? `?${qs}` : ""}`); } -export const getProduct = (id: string): Promise => - api.get(`/products/${id}`); +export const getProduct = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).get(`/products/${id}`); + export const createProduct = ( body: Record, -): Promise => api.post("/products", body); + options?: ApiClientOptions, +): Promise => resolveClient(options).post("/products", body); + export const updateProduct = ( id: string, body: Record, -): Promise => api.patch(`/products/${id}`, body); -export const deleteProduct = (id: string): Promise => - api.del(`/products/${id}`); + options?: ApiClientOptions, +): Promise => resolveClient(options).patch(`/products/${id}`, body); + +export const deleteProduct = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/products/${id}`); + +export const getInventory = ( + productId: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).get(`/products/${productId}/inventory`); -export const getInventory = (productId: string): Promise => - api.get(`/products/${productId}/inventory`); export const createInventory = ( productId: string, body: Record, + options?: ApiClientOptions, ): Promise => - api.post(`/products/${productId}/inventory`, body); + resolveClient(options).post(`/products/${productId}/inventory`, body); + export const updateInventory = ( id: string, body: Record, -): Promise => api.patch(`/inventory/${id}`, body); -export const deleteInventory = (id: string): Promise => - api.del(`/inventory/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/inventory/${id}`, body); -export const parseProductText = (text: string): Promise => - api.post("/products/parse-text", { text }); +export const deleteInventory = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/inventory/${id}`); -// ─── Routines ──────────────────────────────────────────────────────────────── +export const parseProductText = ( + text: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/products/parse-text", { text }); export interface RoutineListParams { from_date?: string; @@ -133,68 +202,103 @@ export interface RoutineListParams { export function getRoutines( params: RoutineListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); if (params.part_of_day) q.set("part_of_day", params.part_of_day); const qs = q.toString(); - return api.get(`/routines${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/routines${qs ? `?${qs}` : ""}`); } -export const getRoutine = (id: string): Promise => - api.get(`/routines/${id}`); +export const getRoutine = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).get(`/routines/${id}`); + export const createRoutine = ( body: Record, -): Promise => api.post("/routines", body); + options?: ApiClientOptions, +): Promise => resolveClient(options).post("/routines", body); + export const updateRoutine = ( id: string, body: Record, -): Promise => api.patch(`/routines/${id}`, body); -export const deleteRoutine = (id: string): Promise => - api.del(`/routines/${id}`); + options?: ApiClientOptions, +): Promise => resolveClient(options).patch(`/routines/${id}`, body); + +export const deleteRoutine = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/routines/${id}`); export const addRoutineStep = ( routineId: string, body: Record, -): Promise => api.post(`/routines/${routineId}/steps`, body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post(`/routines/${routineId}/steps`, body); + export const updateRoutineStep = ( stepId: string, body: Record, -): Promise => api.patch(`/routines/steps/${stepId}`, body); -export const deleteRoutineStep = (stepId: string): Promise => - api.del(`/routines/steps/${stepId}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/routines/steps/${stepId}`, body); -export const suggestRoutine = (body: { - routine_date: string; - part_of_day: PartOfDay; - notes?: string; - include_minoxidil_beard?: boolean; - leaving_home?: boolean; -}): Promise => api.post("/routines/suggest", body); +export const deleteRoutineStep = ( + stepId: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/routines/steps/${stepId}`); -export const suggestBatch = (body: { - from_date: string; - to_date: string; - notes?: string; - include_minoxidil_beard?: boolean; - minimize_products?: boolean; -}): Promise => api.post("/routines/suggest-batch", body); +export const suggestRoutine = ( + body: { + routine_date: string; + part_of_day: PartOfDay; + notes?: string; + include_minoxidil_beard?: boolean; + leaving_home?: boolean; + }, + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/routines/suggest", body); + +export const suggestBatch = ( + body: { + from_date: string; + to_date: string; + notes?: string; + include_minoxidil_beard?: boolean; + minimize_products?: boolean; + }, + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/routines/suggest-batch", body); + +export const getGroomingSchedule = ( + options?: ApiClientOptions, +): Promise => + resolveClient(options).get("/routines/grooming-schedule"); -export const getGroomingSchedule = (): Promise => - api.get("/routines/grooming-schedule"); export const createGroomingScheduleEntry = ( body: Record, -): Promise => api.post("/routines/grooming-schedule", body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/routines/grooming-schedule", body); + export const updateGroomingScheduleEntry = ( id: string, body: Record, + options?: ApiClientOptions, ): Promise => - api.patch(`/routines/grooming-schedule/${id}`, body); -export const deleteGroomingScheduleEntry = (id: string): Promise => - api.del(`/routines/grooming-schedule/${id}`); + resolveClient(options).patch(`/routines/grooming-schedule/${id}`, body); -// ─── Health – Medications ──────────────────────────────────────────────────── +export const deleteGroomingScheduleEntry = ( + id: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).del(`/routines/grooming-schedule/${id}`); export interface MedicationListParams { kind?: string; @@ -203,37 +307,51 @@ export interface MedicationListParams { export function getMedications( params: MedicationListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.kind) q.set("kind", params.kind); if (params.product_name) q.set("product_name", params.product_name); const qs = q.toString(); - return api.get(`/health/medications${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/health/medications${qs ? `?${qs}` : ""}`); } -export const getMedication = (id: string): Promise => - api.get(`/health/medications/${id}`); +export const getMedication = ( + id: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).get(`/health/medications/${id}`); + export const createMedication = ( body: Record, -): Promise => api.post("/health/medications", body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/health/medications", body); + export const updateMedication = ( id: string, body: Record, -): Promise => api.patch(`/health/medications/${id}`, body); -export const deleteMedication = (id: string): Promise => - api.del(`/health/medications/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/health/medications/${id}`, body); + +export const deleteMedication = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/health/medications/${id}`); export const getMedicationUsages = ( medicationId: string, + options?: ApiClientOptions, ): Promise => - api.get(`/health/medications/${medicationId}/usages`); + resolveClient(options).get(`/health/medications/${medicationId}/usages`); + export const createMedicationUsage = ( medicationId: string, body: Record, + options?: ApiClientOptions, ): Promise => - api.post(`/health/medications/${medicationId}/usages`, body); - -// ─── Health – Lab results ──────────────────────────────────────────────────── + resolveClient(options).post(`/health/medications/${medicationId}/usages`, body); export interface LabResultListParams { q?: string; @@ -250,6 +368,7 @@ export interface LabResultListParams { export function getLabResults( params: LabResultListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.q) q.set("q", params.q); @@ -258,29 +377,43 @@ export function getLabResults( if (params.flags?.length) { for (const flag of params.flags) q.append("flags", flag); } - if (params.without_flag != null) q.set("without_flag", String(params.without_flag)); + if (params.without_flag != null) { + q.set("without_flag", String(params.without_flag)); + } if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); - if (params.latest_only != null) q.set("latest_only", String(params.latest_only)); + if (params.latest_only != null) { + q.set("latest_only", String(params.latest_only)); + } if (params.limit != null) q.set("limit", String(params.limit)); if (params.offset != null) q.set("offset", String(params.offset)); const qs = q.toString(); - return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/health/lab-results${qs ? `?${qs}` : ""}`); } -export const getLabResult = (id: string): Promise => - api.get(`/health/lab-results/${id}`); +export const getLabResult = ( + id: string, + options?: ApiClientOptions, +): Promise => + resolveClient(options).get(`/health/lab-results/${id}`); + export const createLabResult = ( body: Record, -): Promise => api.post("/health/lab-results", body); + options?: ApiClientOptions, +): Promise => + resolveClient(options).post("/health/lab-results", body); + export const updateLabResult = ( id: string, body: Record, -): Promise => api.patch(`/health/lab-results/${id}`, body); -export const deleteLabResult = (id: string): Promise => - api.del(`/health/lab-results/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/health/lab-results/${id}`, body); -// ─── Skin ──────────────────────────────────────────────────────────────────── +export const deleteLabResult = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/health/lab-results/${id}`); export interface SnapshotListParams { from_date?: string; @@ -290,40 +423,47 @@ export interface SnapshotListParams { export function getSkinSnapshots( params: SnapshotListParams = {}, + options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); if (params.overall_state) q.set("overall_state", params.overall_state); const qs = q.toString(); - return api.get(`/skincare${qs ? `?${qs}` : ""}`); + return resolveClient(options).get(`/skincare${qs ? `?${qs}` : ""}`); } -export const getSkinSnapshot = (id: string): Promise => - api.get(`/skincare/${id}`); +export const getSkinSnapshot = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).get(`/skincare/${id}`); + export const createSkinSnapshot = ( body: Record, -): Promise => api.post("/skincare", body); + options?: ApiClientOptions, +): Promise => resolveClient(options).post("/skincare", body); + export const updateSkinSnapshot = ( id: string, body: Record, -): Promise => api.patch(`/skincare/${id}`, body); -export const deleteSkinSnapshot = (id: string): Promise => - api.del(`/skincare/${id}`); + options?: ApiClientOptions, +): Promise => + resolveClient(options).patch(`/skincare/${id}`, body); + +export const deleteSkinSnapshot = ( + id: string, + options?: ApiClientOptions, +): Promise => resolveClient(options).del(`/skincare/${id}`); export async function analyzeSkinPhotos( - files: File[], + files: File[] | FormData, + options?: ApiClientOptions, ): Promise { - const body = new FormData(); - for (const file of files) body.append("photos", file); - const base = browser ? "/api" : PUBLIC_API_BASE; - const res = await fetch(`${base}/skincare/analyze-photos`, { - method: "POST", - body, - }); - if (!res.ok) { - const detail = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(detail?.detail ?? res.statusText); + const body = files instanceof FormData ? files : new FormData(); + + if (!(files instanceof FormData)) { + for (const file of files) body.append("photos", file); } - return res.json(); + + return resolveClient(options).postForm("/skincare/analyze-photos", body); } diff --git a/frontend/src/lib/components/ProductForm.svelte b/frontend/src/lib/components/ProductForm.svelte index 23dc172..fbf5bda 100644 --- a/frontend/src/lib/components/ProductForm.svelte +++ b/frontend/src/lib/components/ProductForm.svelte @@ -1,4 +1,4 @@ -