diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index bd7e971..906d202 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session @@ -8,6 +8,7 @@ from app.core.config import settings from app.core.security import issue_token from app.db.session import get_db from app.models.client_access import ClientAccount +from app.services.client_access_service import get_client_user_by_email, module_access_map router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -22,28 +23,70 @@ class SessionResponse(BaseModel): email: str role: str tenant_id: str | None = None + client_role: str | None = None + user_id: int | None = None + client_account_id: int | None = None + module_permissions: dict[str, str] = Field(default_factory=dict) token: str -def _build_session_response(*, name: str, email: str, role: str, tenant_id: str | None = None) -> SessionResponse: - token = issue_token({"name": name, "email": email, "role": role, "tenant_id": tenant_id}) - return SessionResponse(name=name, email=email, role=role, tenant_id=tenant_id, token=token) +def _build_session_response( + *, + name: str, + email: str, + role: str, + tenant_id: str | None = None, + client_role: str | None = None, + user_id: int | None = None, + client_account_id: int | None = None, + module_permissions: dict[str, str] | None = None, +) -> SessionResponse: + token = issue_token( + { + "name": name, + "email": email, + "role": role, + "tenant_id": tenant_id, + "client_role": client_role, + "user_id": user_id, + "client_account_id": client_account_id, + } + ) + return SessionResponse( + name=name, + email=email, + role=role, + tenant_id=tenant_id, + client_role=client_role, + user_id=user_id, + client_account_id=client_account_id, + module_permissions=module_permissions or {}, + token=token, + ) @router.post("/client/login", response_model=SessionResponse) def client_login(payload: LoginRequest, db: Session = Depends(get_db)): - if payload.email.strip().lower() != settings.client_email.lower() or payload.password != settings.client_password: + if payload.password != settings.client_password: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password") - client_account = db.scalar(select(ClientAccount).where(ClientAccount.tenant_id == settings.client_tenant_id)) + user = get_client_user_by_email(db, email=payload.email.strip().lower()) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password") + + client_account = db.scalar(select(ClientAccount).where(ClientAccount.id == user.client_account_id)) if client_account is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured for this user") return _build_session_response( - name=settings.client_name, - email=settings.client_email, + name=user.full_name, + email=user.email, role="client", tenant_id=client_account.tenant_id, + client_role=user.role, + user_id=user.id, + client_account_id=client_account.id, + module_permissions=module_access_map(user), ) @@ -56,8 +99,20 @@ def admin_login(payload: LoginRequest): @router.get("/client/session", response_model=SessionResponse) -def read_client_session(session: AuthSession = Depends(require_client_session)): - return _build_session_response(name=session.name, email=session.email, role=session.role, tenant_id=session.tenant_id) +def read_client_session(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): + user = get_client_user_by_email(db, email=session.email, tenant_id=session.tenant_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client user session is no longer available") + return _build_session_response( + name=user.full_name, + email=user.email, + role=session.role, + tenant_id=session.tenant_id, + client_role=user.role, + user_id=user.id, + client_account_id=user.client_account_id, + module_permissions=module_access_map(user), + ) @router.get("/admin/session", response_model=SessionResponse) diff --git a/backend/app/api/client_access.py b/backend/app/api/client_access.py index f87fa5d..17784e1 100644 --- a/backend/app/api/client_access.py +++ b/backend/app/api/client_access.py @@ -1,45 +1,111 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload -from app.api.deps import require_admin_session +from app.api.deps import AuthSession, require_client_access_manager_session from app.db.session import get_db -from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser -from app.schemas.client_access import ClientAccessRead, ClientFeatureUpdate, ClientUserCreate, ClientUserUpdate -from app.services.client_access_service import list_client_accounts, serialize_client_account +from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission +from app.schemas.client_access import ( + ClientAccessRead, + ClientFeatureUpdate, + ClientUserCreate, + ClientUserModulePermissionUpdate, + ClientUserUpdate, +) +from app.services.client_access_service import ( + ACCESS_LEVEL_ORDER, + MODULE_INDEX, + default_access_level_for_role, + ensure_user_module_permissions, + list_client_accounts, + record_audit_event, + serialize_client_account, +) router = APIRouter(prefix="/api/client-access", tags=["client-access"]) -def _get_client_or_404(db: Session, client_id: int) -> ClientAccount: - client = db.scalar(select(ClientAccount).where(ClientAccount.id == client_id)) +def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]: + clients = list_client_accounts(db) + if session.role == "admin": + return clients + return [client for client in clients if client.id == session.client_account_id] + + +def _get_client_or_404(db: Session, client_id: int, session: AuthSession) -> ClientAccount: + clients = _authorized_client_scope(db, session) + client = next((client for client in clients if client.id == client_id), None) if client is None: raise HTTPException(status_code=404, detail="Client account not found") return client -def _read_client_account(db: Session, client_id: int) -> dict: - client = next((item for item in list_client_accounts(db) if item.id == client_id), None) +def _get_user_or_404(db: Session, user_id: int, session: AuthSession) -> ClientUser: + user = db.scalar( + select(ClientUser) + .where(ClientUser.id == user_id) + .options(selectinload(ClientUser.module_permissions)) + ) + if user is None: + raise HTTPException(status_code=404, detail="Client user not found") + _get_client_or_404(db, user.client_account_id, session) + return user + + +def _read_client_account(db: Session, client_id: int, session: AuthSession) -> dict: + client = next((item for item in _authorized_client_scope(db, session) if item.id == client_id), None) if client is None: raise HTTPException(status_code=404, detail="Client account not found") return serialize_client_account(client) +def _actor_metadata(session: AuthSession) -> dict[str, str]: + if session.role == "admin": + return { + "actor_type": "lean_admin", + "actor_name": session.name, + "actor_email": session.email, + "actor_role": "admin", + } + return { + "actor_type": "client_superadmin", + "actor_name": session.name, + "actor_email": session.email, + "actor_role": session.client_role or "client", + } + + @router.get("", response_model=list[ClientAccessRead]) -def get_client_access(db: Session = Depends(get_db), _: object = Depends(require_admin_session)): - return [serialize_client_account(client) for client in list_client_accounts(db)] +def get_client_access( + db: Session = Depends(get_db), + session: AuthSession = Depends(require_client_access_manager_session), +): + return [serialize_client_account(client) for client in _authorized_client_scope(db, session)] @router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED) def create_client_user( payload: ClientUserCreate, db: Session = Depends(get_db), - _: object = Depends(require_admin_session), + session: AuthSession = Depends(require_client_access_manager_session), ): - client = _get_client_or_404(db, payload.client_account_id) + client = _get_client_or_404(db, payload.client_account_id, session) user = ClientUser(tenant_id=client.tenant_id, **payload.model_dump()) db.add(user) + db.flush() + ensure_user_module_permissions(db, user) + record_audit_event( + db, + tenant_id=client.tenant_id, + client_account_id=client.id, + action="user.created", + target_type="client_user", + target_id=user.id, + module_key="client_access", + summary=f"{user.full_name} was created with the {user.role} role.", + **_actor_metadata(session), + ) try: db.commit() @@ -47,25 +113,93 @@ def create_client_user( db.rollback() raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc - return _read_client_account(db, payload.client_account_id) + return _read_client_account(db, payload.client_account_id, session) @router.patch("/users/{user_id}", response_model=ClientAccessRead) -def update_client_user(user_id: int, payload: ClientUserUpdate, db: Session = Depends(get_db), _: object = Depends(require_admin_session)): - user = db.scalar(select(ClientUser).where(ClientUser.id == user_id)) - if user is None: - raise HTTPException(status_code=404, detail="Client user not found") +def update_client_user( + user_id: int, + payload: ClientUserUpdate, + db: Session = Depends(get_db), + session: AuthSession = Depends(require_client_access_manager_session), +): + user = _get_user_or_404(db, user_id, session) + changes = payload.model_dump(exclude_unset=True) + original_role = user.role - for field, value in payload.model_dump(exclude_unset=True).items(): + for field, value in changes.items(): setattr(user, field, value) + if "role" in changes and changes["role"] != original_role: + for permission in user.module_permissions: + permission.access_level = default_access_level_for_role(user.role, permission.module_key) + + record_audit_event( + db, + tenant_id=user.tenant_id, + client_account_id=user.client_account_id, + action="user.updated", + target_type="client_user", + target_id=user.id, + module_key="client_access", + summary=f"{user.full_name} access was updated.", + **_actor_metadata(session), + ) + try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc - return _read_client_account(db, user.client_account_id) + return _read_client_account(db, user.client_account_id, session) + + +@router.patch("/users/{user_id}/module-permissions/{module_key}", response_model=ClientAccessRead) +def update_client_user_module_permission( + user_id: int, + module_key: str, + payload: ClientUserModulePermissionUpdate, + db: Session = Depends(get_db), + session: AuthSession = Depends(require_client_access_manager_session), +): + user = _get_user_or_404(db, user_id, session) + if payload.access_level not in ACCESS_LEVEL_ORDER: + raise HTTPException(status_code=422, detail="Invalid access level") + permission = db.scalar( + select(ClientUserModulePermission).where( + ClientUserModulePermission.client_user_id == user.id, + ClientUserModulePermission.module_key == module_key, + ) + ) + if permission is None: + if module_key not in MODULE_INDEX: + raise HTTPException(status_code=404, detail="Module permission not found") + permission = ClientUserModulePermission( + tenant_id=user.tenant_id, + client_account_id=user.client_account_id, + client_user_id=user.id, + module_key=module_key, + access_level=default_access_level_for_role(user.role, module_key), + ) + db.add(permission) + db.flush() + + permission.access_level = payload.access_level + module_name = MODULE_INDEX.get(module_key, {}).get("module_name", module_key.replace("_", " ").title()) + record_audit_event( + db, + tenant_id=user.tenant_id, + client_account_id=user.client_account_id, + action="module_permission.updated", + target_type="client_user_module_permission", + target_id=permission.id, + module_key=module_key, + summary=f"{user.full_name} now has {payload.access_level} access to {module_name}.", + **_actor_metadata(session), + ) + db.commit() + return _read_client_account(db, user.client_account_id, session) @router.patch("/features/{feature_id}", response_model=ClientAccessRead) @@ -73,12 +207,24 @@ def update_client_feature( feature_id: int, payload: ClientFeatureUpdate, db: Session = Depends(get_db), - _: object = Depends(require_admin_session), + session: AuthSession = Depends(require_client_access_manager_session), ): feature = db.scalar(select(ClientFeatureAccess).where(ClientFeatureAccess.id == feature_id)) if feature is None: raise HTTPException(status_code=404, detail="Client feature not found") + _get_client_or_404(db, feature.client_account_id, session) feature.enabled = payload.enabled + record_audit_event( + db, + tenant_id=feature.tenant_id, + client_account_id=feature.client_account_id, + action="feature.updated", + target_type="client_feature", + target_id=feature.id, + module_key=feature.feature_key, + summary=f"{feature.feature_name} was {'enabled' if payload.enabled else 'disabled'}.", + **_actor_metadata(session), + ) db.commit() - return _read_client_account(db, feature.client_account_id) + return _read_client_account(db, feature.client_account_id, session) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index b38e3e4..d1a8c11 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -4,8 +4,13 @@ from dataclasses import dataclass from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload from app.core.security import verify_token +from app.db.session import get_db +from app.models.client_access import ClientFeatureAccess, ClientUser +from app.services.client_access_service import has_access_level, module_access_map bearer_scheme = HTTPBearer(auto_error=False) @@ -16,6 +21,10 @@ class AuthSession: email: str name: str tenant_id: str | None = None + client_role: str | None = None + user_id: int | None = None + client_account_id: int | None = None + module_permissions: dict[str, str] | None = None def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession: @@ -28,6 +37,10 @@ def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends( email=str(payload.get("email", "")), name=str(payload.get("name", "")), tenant_id=payload.get("tenant_id"), + client_role=payload.get("client_role"), + user_id=payload.get("user_id"), + client_account_id=payload.get("client_account_id"), + module_permissions={}, ) @@ -36,6 +49,8 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) -> raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required") if not session.tenant_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client tenant is missing") + if not session.user_id or not session.client_account_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user context is missing") return session @@ -43,3 +58,80 @@ def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> A if session.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return session + + +def load_current_client_user(db: Session, session: AuthSession) -> ClientUser: + user = db.scalar( + select(ClientUser) + .where( + ClientUser.id == session.user_id, + ClientUser.client_account_id == session.client_account_id, + ClientUser.tenant_id == session.tenant_id, + ) + .options(selectinload(ClientUser.module_permissions)) + ) + if user is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is no longer valid") + if user.status == "suspended": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is suspended") + return user + + +def require_client_module_access(module_key: str, minimum_level: str = "view"): + def dependency( + session: AuthSession = Depends(require_client_session), + db: Session = Depends(get_db), + ) -> AuthSession: + user = load_current_client_user(db, session) + feature = db.scalar( + select(ClientFeatureAccess).where( + ClientFeatureAccess.client_account_id == user.client_account_id, + ClientFeatureAccess.tenant_id == user.tenant_id, + ClientFeatureAccess.feature_key == module_key, + ) + ) + if feature is not None and not feature.enabled: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} is disabled for this client") + + permissions = module_access_map(user) + if not has_access_level(permissions.get(module_key), minimum_level): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted") + + return AuthSession( + role=session.role, + email=session.email, + name=session.name, + tenant_id=session.tenant_id, + client_role=user.role, + user_id=user.id, + client_account_id=user.client_account_id, + module_permissions=permissions, + ) + + return dependency + + +def require_client_access_manager_session( + session: AuthSession = Depends(get_auth_session), + db: Session = Depends(get_db), +) -> AuthSession: + if session.role == "admin": + return session + if session.role != "client": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access") + + user = load_current_client_user(db, require_client_session(session)) + permissions = module_access_map(user) + if user.role != "superadmin" or not has_access_level(permissions.get("client_access"), "manage"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required") + + return AuthSession( + role=session.role, + email=session.email, + name=session.name, + tenant_id=session.tenant_id, + client_role=user.role, + user_id=user.id, + client_account_id=user.client_account_id, + module_permissions=permissions, + ) diff --git a/backend/app/api/mixes.py b/backend/app/api/mixes.py index b7f77f6..0ed1e63 100644 --- a/backend/app/api/mixes.py +++ b/backend/app/api/mixes.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session -from app.api.deps import AuthSession, require_client_session +from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.mix import Mix, MixIngredient from app.models.raw_material import RawMaterial @@ -13,13 +13,13 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"]) @router.get("", response_model=list[MixRead]) -def list_mixes(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def list_mixes(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)): mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all() return [calculate_mix_cost(db, mix.id) for mix in mixes] @router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED) -def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)): mix = Mix( tenant_id=session.tenant_id, client_name=payload.client_name, @@ -52,14 +52,14 @@ def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client @router.get("/{mix_id}", response_model=MixRead) -def get_mix(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_mix(mix_id: int, session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)): if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Mix not found") return calculate_mix_cost(db, mix_id) @router.patch("/{mix_id}", response_model=MixRead) -def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)): mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) if mix is None: raise HTTPException(status_code=404, detail="Mix not found") @@ -70,7 +70,7 @@ def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(r @router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED) -def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)): if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Mix not found") if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None: @@ -93,7 +93,7 @@ def update_mix_ingredient( mix_id: int, ingredient_id: int, payload: MixIngredientUpdate, - session: AuthSession = Depends(require_client_session), + session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db), ): ingredient = db.scalar( @@ -112,7 +112,7 @@ def update_mix_ingredient( @router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead) -def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)): ingredient = db.scalar( select(MixIngredient).where( MixIngredient.id == ingredient_id, @@ -128,7 +128,7 @@ def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession @router.get("/{mix_id}/cost-breakdown", response_model=MixRead) -def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)): if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Mix not found") return calculate_mix_cost(db, mix_id) diff --git a/backend/app/api/powerbi.py b/backend/app/api/powerbi.py index 89e9815..9010e0a 100644 --- a/backend/app/api/powerbi.py +++ b/backend/app/api/powerbi.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session -from app.api.deps import AuthSession, require_admin_session, require_client_session +from app.api.deps import AuthSession, require_client_access_manager_session, require_client_module_access from app.db.session import get_db from app.models.mix import Mix from app.models.product import Product @@ -15,25 +15,25 @@ router = APIRouter(prefix="/api/powerbi", tags=["powerbi"]) @router.get("/raw-material-costs") -def raw_material_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def raw_material_costs(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): materials = db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id).order_by(RawMaterial.name)).all() return [serialize_raw_material(material) for material in materials] @router.get("/mix-costs") -def mix_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def mix_costs(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)): mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all() return [calculate_mix_cost(db, mix.id) for mix in mixes] @router.get("/product-costs") -def product_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def product_costs(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)): products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all() return [calculate_product_cost(db, product.id) for product in products] @router.get("/scenario-results") -def scenario_results(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def scenario_results(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)): scenarios = db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all() return [ { @@ -48,12 +48,18 @@ def scenario_results(session: AuthSession = Depends(require_client_session), db: @router.get("/client-access") -def client_access_export(_: AuthSession = Depends(require_admin_session), db: Session = Depends(get_db)): - return build_client_access_export(list_client_accounts(db)) +def client_access_export( + session: AuthSession = Depends(require_client_access_manager_session), + db: Session = Depends(get_db), +): + clients = list_client_accounts(db) + if session.role == "client": + clients = [client for client in clients if client.id == session.client_account_id] + return build_client_access_export(clients) @router.get("/data-quality-issues") -def data_quality_issues(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def data_quality_issues(session: AuthSession = Depends(require_client_module_access("dashboard")), db: Session = Depends(get_db)): issues: list[dict] = [] for mix in db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id)).all(): result = calculate_mix_cost(db, mix.id) diff --git a/backend/app/api/products.py b/backend/app/api/products.py index 75058db..528a368 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session -from app.api.deps import AuthSession, require_client_session +from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.mix import Mix from app.models.product import Product @@ -34,13 +34,13 @@ def _serialize_product(product: Product) -> dict: @router.get("", response_model=list[ProductRead]) -def list_products(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def list_products(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)): products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all() return [_serialize_product(product) for product in products] @router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED) -def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_module_access("products", "edit")), db: Session = Depends(get_db)): if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Mix not found") product = Product(tenant_id=session.tenant_id, **payload.model_dump()) @@ -51,7 +51,7 @@ def create_product(payload: ProductCreate, session: AuthSession = Depends(requir @router.get("/{product_id}", response_model=ProductRead) -def get_product(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_product(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)): product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) if product is None: raise HTTPException(status_code=404, detail="Product not found") @@ -59,7 +59,7 @@ def get_product(product_id: int, session: AuthSession = Depends(require_client_s @router.patch("/{product_id}", response_model=ProductRead) -def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_module_access("products", "edit")), db: Session = Depends(get_db)): product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) if product is None: raise HTTPException(status_code=404, detail="Product not found") @@ -73,7 +73,7 @@ def update_product(product_id: int, payload: ProductUpdate, session: AuthSession @router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown) -def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)): if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Product not found") try: @@ -83,7 +83,7 @@ def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(r @router.get("/{product_id}/price-output", response_model=ProductCostBreakdown) -def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)): if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Product not found") try: diff --git a/backend/app/api/raw_materials.py b/backend/app/api/raw_materials.py index 0cb3bfb..5047662 100644 --- a/backend/app/api/raw_materials.py +++ b/backend/app/api/raw_materials.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from app.api.deps import AuthSession, require_client_session +from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.schemas.raw_material import ( @@ -34,7 +34,7 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d @router.get("", response_model=list[RawMaterialRead]) -def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): materials = db.scalars( select(RawMaterial) .where(RawMaterial.tenant_id == session.tenant_id) @@ -45,7 +45,7 @@ def list_raw_materials(session: AuthSession = Depends(require_client_session), d @router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED) -def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")), db: Session = Depends(get_db)): material = RawMaterial( tenant_id=session.tenant_id, name=payload.name, @@ -72,7 +72,7 @@ def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depen @router.get("/{raw_material_id}", response_model=RawMaterialRead) -def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): material = db.scalar( select(RawMaterial) .where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id) @@ -87,7 +87,7 @@ def get_raw_material(raw_material_id: int, session: AuthSession = Depends(requir def update_raw_material( raw_material_id: int, payload: RawMaterialUpdate, - session: AuthSession = Depends(require_client_session), + session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")), db: Session = Depends(get_db), ): material = db.scalar( @@ -108,7 +108,7 @@ def update_raw_material( def add_price_version( raw_material_id: int, payload: RawMaterialPriceVersionCreate, - session: AuthSession = Depends(require_client_session), + session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")), db: Session = Depends(get_db), ): material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)) @@ -130,7 +130,7 @@ def add_price_version( @router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead]) -def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)) if material is None: raise HTTPException(status_code=404, detail="Raw material not found") diff --git a/backend/app/api/scenarios.py b/backend/app/api/scenarios.py index f82e75e..22339ab 100644 --- a/backend/app/api/scenarios.py +++ b/backend/app/api/scenarios.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session -from app.api.deps import AuthSession, require_client_session +from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.scenario import CostingResult, Scenario from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse @@ -12,12 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) @router.get("", response_model=list[ScenarioRead]) -def list_scenarios(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def list_scenarios(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)): return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all() @router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED) -def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)): scenario = Scenario(tenant_id=session.tenant_id, name=payload.name, description=payload.description, overrides=payload.overrides) db.add(scenario) db.commit() @@ -26,7 +26,7 @@ def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(requ @router.get("/{scenario_id}", response_model=ScenarioRead) -def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)): scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") @@ -34,7 +34,7 @@ def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client @router.post("/{scenario_id}/run", response_model=ScenarioRunResponse) -def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)): scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") @@ -44,7 +44,7 @@ def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(requi @router.get("/{scenario_id}/results") -def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)): scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") @@ -65,7 +65,7 @@ def get_scenario_results(scenario_id: int, session: AuthSession = Depends(requir @router.post("/{scenario_id}/approve", response_model=ScenarioRead) -def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)): scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") @@ -76,7 +76,7 @@ def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_cl @router.post("/{scenario_id}/reject", response_model=ScenarioRead) -def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): +def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)): scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) if scenario is None: raise HTTPException(status_code=404, detail="Scenario not found") diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 4c9d17c..97a3e7d 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -9,6 +9,8 @@ from sqlalchemy.engine import Engine TENANT_TABLES = { "client_users": None, "client_feature_access": None, + "client_user_module_permissions": None, + "client_access_audit_events": None, "raw_materials": None, "raw_material_price_versions": None, "mixes": None, @@ -123,6 +125,34 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]: """ ), ), + ( + "client_user_module_permissions", + text( + """ + UPDATE client_user_module_permissions + SET tenant_id = ( + SELECT client_users.tenant_id + FROM client_users + WHERE client_users.id = client_user_module_permissions.client_user_id + ) + WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' + """ + ), + ), + ( + "client_access_audit_events", + text( + """ + UPDATE client_access_audit_events + SET tenant_id = ( + SELECT client_accounts.tenant_id + FROM client_accounts + WHERE client_accounts.id = client_access_audit_events.client_account_id + ) + WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' + """ + ), + ), ( "raw_materials", text( diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b7ec4d1..6c8d677 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,5 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule -from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser +from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission from app.models.mix import Mix, MixIngredient from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion @@ -7,8 +7,10 @@ from app.models.scenario import CostingResult, Scenario __all__ = [ "ClientAccount", + "ClientAccessAuditEvent", "ClientFeatureAccess", "ClientUser", + "ClientUserModulePermission", "CostingResult", "FreightCostRule", "Mix", diff --git a/backend/app/models/client_access.py b/backend/app/models/client_access.py index 259ba22..8de3c34 100644 --- a/backend/app/models/client_access.py +++ b/backend/app/models/client_access.py @@ -30,6 +30,11 @@ class ClientAccount(Base): cascade="all, delete-orphan", order_by="ClientFeatureAccess.feature_group, ClientFeatureAccess.feature_name", ) + audit_events: Mapped[list["ClientAccessAuditEvent"]] = relationship( + back_populates="client_account", + cascade="all, delete-orphan", + order_by="ClientAccessAuditEvent.created_at.desc()", + ) class ClientUser(Base): @@ -48,6 +53,11 @@ class ClientUser(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) client_account: Mapped[ClientAccount] = relationship(back_populates="users") + module_permissions: Mapped[list["ClientUserModulePermission"]] = relationship( + back_populates="client_user", + cascade="all, delete-orphan", + order_by="ClientUserModulePermission.module_key", + ) class ClientFeatureAccess(Base): @@ -66,3 +76,39 @@ class ClientFeatureAccess(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) client_account: Mapped[ClientAccount] = relationship(back_populates="features") + + +class ClientUserModulePermission(Base): + __tablename__ = "client_user_module_permissions" + __table_args__ = (UniqueConstraint("client_user_id", "module_key", name="uq_client_user_module_permission"),) + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default") + client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True) + client_user_id: Mapped[int] = mapped_column(ForeignKey("client_users.id"), index=True) + module_key: Mapped[str] = mapped_column(String(64)) + access_level: Mapped[str] = mapped_column(String(32), default="none") + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + client_user: Mapped[ClientUser] = relationship(back_populates="module_permissions") + + +class ClientAccessAuditEvent(Base): + __tablename__ = "client_access_audit_events" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default") + client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True) + actor_type: Mapped[str] = mapped_column(String(32), default="client") + actor_name: Mapped[str] = mapped_column(String(255)) + actor_email: Mapped[str] = mapped_column(String(255)) + actor_role: Mapped[str] = mapped_column(String(64)) + action: Mapped[str] = mapped_column(String(128)) + target_type: Mapped[str] = mapped_column(String(64)) + target_id: Mapped[int | None] = mapped_column(nullable=True) + module_key: Mapped[str | None] = mapped_column(String(64), nullable=True) + summary: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + client_account: Mapped[ClientAccount] = relationship(back_populates="audit_events") diff --git a/backend/app/schemas/client_access.py b/backend/app/schemas/client_access.py index bd88c27..a7cc861 100644 --- a/backend/app/schemas/client_access.py +++ b/backend/app/schemas/client_access.py @@ -24,6 +24,10 @@ class ClientFeatureUpdate(BaseModel): enabled: bool +class ClientUserModulePermissionUpdate(BaseModel): + access_level: str + + class ClientUserRead(BaseModel): id: int client_account_id: int @@ -34,6 +38,7 @@ class ClientUserRead(BaseModel): is_new_user: bool last_login_at: datetime | None created_at: datetime + module_permissions: list["ClientUserModulePermissionRead"] class ClientFeatureRead(BaseModel): @@ -48,6 +53,34 @@ class ClientFeatureRead(BaseModel): created_at: datetime +class ClientUserModulePermissionRead(BaseModel): + id: int + client_account_id: int + client_user_id: int + module_key: str + module_name: str + module_group: str + description: str | None + access_level: str + updated_at: datetime + created_at: datetime + + +class ClientAccessAuditEventRead(BaseModel): + id: int + client_account_id: int + actor_type: str + actor_name: str + actor_email: str + actor_role: str + action: str + target_type: str + target_id: int | None + module_key: str | None + summary: str + created_at: datetime + + class ClientAccessRead(BaseModel): id: int tenant_id: str @@ -63,3 +96,7 @@ class ClientAccessRead(BaseModel): new_user_count: int enabled_feature_count: int total_feature_count: int + audit_history: list[ClientAccessAuditEventRead] + + +ClientUserRead.model_rebuild() diff --git a/backend/app/seed.py b/backend/app/seed.py index 65097d7..9ca7d0c 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -4,20 +4,11 @@ from sqlalchemy import select from app.db.session import Base, SessionLocal, engine from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule -from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser +from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission from app.models.mix import Mix, MixIngredient from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion - - -CLIENT_FEATURES = [ - ("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"), - ("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"), - ("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"), - ("products", "Products", "pricing", "Review finished product pricing"), - ("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"), - ("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"), -] +from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role def seed_client_access(db): @@ -51,7 +42,7 @@ def seed_client_access(db): tenant_id=specialty.tenant_id, full_name="Amelia Hart", email="operator@example.com", - role="admin", + role="superadmin", status="active", is_new_user=False, last_login_at=datetime(2026, 4, 24, 11, 30), @@ -81,13 +72,13 @@ def seed_client_access(db): ) enabled_feature_map = { - "hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export"}, + "hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export", "client_access"}, "loft-grains": {"dashboard", "products", "powerbi_export"}, } for client in (specialty, loft): enabled_keys = enabled_feature_map[client.tenant_id] - for feature_key, feature_name, feature_group, description in CLIENT_FEATURES: + for feature_key, feature_name, feature_group, description in MODULE_CATALOG: client.features.append( ClientFeatureAccess( tenant_id=client.tenant_id, @@ -99,6 +90,32 @@ def seed_client_access(db): ) ) + for user in client.users: + for module_key, _, _, _ in MODULE_CATALOG: + user.module_permissions.append( + ClientUserModulePermission( + tenant_id=client.tenant_id, + client_account_id=client.id, + module_key=module_key, + access_level=default_access_level_for_role(user.role, module_key), + ) + ) + + specialty.audit_events.append( + ClientAccessAuditEvent( + tenant_id=specialty.tenant_id, + actor_type="seed", + actor_name="Lean 101 Seeder", + actor_email="system@lean101.local", + actor_role="system", + action="client_access.seeded", + target_type="client_account", + target_id=specialty.id, + module_key="client_access", + summary="Initial client access controls, module permissions, and feature flags were seeded.", + ) + ) + def seed_costing_workspace(db): existing = db.scalar(select(RawMaterial.id)) diff --git a/backend/app/services/client_access_service.py b/backend/app/services/client_access_service.py index d7a2d37..66ab26f 100644 --- a/backend/app/services/client_access_service.py +++ b/backend/app/services/client_access_service.py @@ -5,21 +5,107 @@ from datetime import datetime from sqlalchemy import Select, select from sqlalchemy.orm import Session, selectinload -from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser +from app.models.client_access import ( + ClientAccessAuditEvent, + ClientAccount, + ClientFeatureAccess, + ClientUser, + ClientUserModulePermission, +) + +MODULE_CATALOG = ( + ("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"), + ("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"), + ("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"), + ("products", "Products", "pricing", "Review finished product pricing"), + ("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"), + ("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"), + ("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"), +) +MODULE_INDEX = { + key: {"module_name": name, "module_group": group, "description": description} + for key, name, group, description in MODULE_CATALOG +} +ACCESS_LEVEL_ORDER = {"none": 0, "view": 1, "edit": 2, "manage": 3} def client_access_query() -> Select[tuple[ClientAccount]]: return ( select(ClientAccount) - .options(selectinload(ClientAccount.users), selectinload(ClientAccount.features)) + .options( + selectinload(ClientAccount.users).selectinload(ClientUser.module_permissions), + selectinload(ClientAccount.features), + selectinload(ClientAccount.audit_events), + ) .order_by(ClientAccount.name) ) def list_client_accounts(db: Session) -> list[ClientAccount]: + ensure_client_user_module_permissions(db) return db.scalars(client_access_query()).all() +def get_client_user_by_email(db: Session, *, email: str, tenant_id: str | None = None) -> ClientUser | None: + statement = select(ClientUser).where(ClientUser.email == email) + if tenant_id: + statement = statement.where(ClientUser.tenant_id == tenant_id) + return db.scalar( + statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc()) + ) + + +def module_access_map(user: ClientUser) -> dict[str, str]: + return {permission.module_key: permission.access_level for permission in user.module_permissions} + + +def has_access_level(access_level: str | None, minimum_level: str) -> bool: + return ACCESS_LEVEL_ORDER.get(access_level or "none", 0) >= ACCESS_LEVEL_ORDER.get(minimum_level, 0) + + +def default_access_level_for_role(role: str, module_key: str) -> str: + normalized = role.strip().lower() + if normalized == "superadmin": + return "manage" if module_key == "client_access" else "edit" + if normalized == "admin": + return "edit" if module_key != "client_access" else "none" + if normalized == "operator": + return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "products", "scenarios"} else "none" + if normalized == "viewer": + return "view" if module_key in {"dashboard", "products", "powerbi_export"} else "none" + return "none" + + +def ensure_user_module_permissions(db: Session, user: ClientUser) -> bool: + existing = {permission.module_key for permission in user.module_permissions} + created = False + + for module_key, _, _, _ in MODULE_CATALOG: + if module_key in existing: + continue + db.add( + ClientUserModulePermission( + tenant_id=user.tenant_id, + client_account_id=user.client_account_id, + client_user_id=user.id, + module_key=module_key, + access_level=default_access_level_for_role(user.role, module_key), + ) + ) + created = True + + return created + + +def ensure_client_user_module_permissions(db: Session) -> None: + users = db.scalars(select(ClientUser).options(selectinload(ClientUser.module_permissions))).all() + changed = False + for user in users: + changed = ensure_user_module_permissions(db, user) or changed + if changed: + db.commit() + + def serialize_client_user(user: ClientUser) -> dict: return { "id": user.id, @@ -31,6 +117,7 @@ def serialize_client_user(user: ClientUser) -> dict: "is_new_user": user.is_new_user, "last_login_at": user.last_login_at, "created_at": user.created_at, + "module_permissions": [serialize_module_permission(permission) for permission in user.module_permissions], } @@ -48,6 +135,39 @@ def serialize_client_feature(feature: ClientFeatureAccess) -> dict: } +def serialize_module_permission(permission: ClientUserModulePermission) -> dict: + module_info = MODULE_INDEX.get(permission.module_key, {}) + return { + "id": permission.id, + "client_account_id": permission.client_account_id, + "client_user_id": permission.client_user_id, + "module_key": permission.module_key, + "module_name": module_info.get("module_name", permission.module_key.replace("_", " ").title()), + "module_group": module_info.get("module_group", "workspace"), + "description": module_info.get("description"), + "access_level": permission.access_level, + "updated_at": permission.updated_at, + "created_at": permission.created_at, + } + + +def serialize_audit_event(event: ClientAccessAuditEvent) -> dict: + return { + "id": event.id, + "client_account_id": event.client_account_id, + "actor_type": event.actor_type, + "actor_name": event.actor_name, + "actor_email": event.actor_email, + "actor_role": event.actor_role, + "action": event.action, + "target_type": event.target_type, + "target_id": event.target_id, + "module_key": event.module_key, + "summary": event.summary, + "created_at": event.created_at, + } + + def serialize_client_account(client: ClientAccount) -> dict: users = [serialize_client_user(user) for user in client.users] features = [serialize_client_feature(feature) for feature in client.features] @@ -70,6 +190,7 @@ def serialize_client_account(client: ClientAccount) -> dict: "new_user_count": new_users, "enabled_feature_count": enabled_features, "total_feature_count": len(features), + "audit_history": [serialize_audit_event(event) for event in client.audit_events[:40]], } @@ -78,6 +199,8 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict: client_rows = [] user_rows = [] feature_rows = [] + permission_rows = [] + audit_rows = [] for client in serialized_clients: client_rows.append( @@ -111,6 +234,21 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict: } ) + for permission in user["module_permissions"]: + permission_rows.append( + { + "client_id": client["id"], + "client_name": client["name"], + "user_id": user["id"], + "user_email": user["email"], + "module_key": permission["module_key"], + "module_name": permission["module_name"], + "module_group": permission["module_group"], + "access_level": permission["access_level"], + "updated_at": permission["updated_at"], + } + ) + for feature in client["features"]: feature_rows.append( { @@ -125,10 +263,61 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict: } ) + for event in client["audit_history"]: + audit_rows.append( + { + "client_id": client["id"], + "client_name": client["name"], + "event_id": event["id"], + "actor_email": event["actor_email"], + "actor_role": event["actor_role"], + "action": event["action"], + "target_type": event["target_type"], + "target_id": event["target_id"], + "module_key": event["module_key"], + "summary": event["summary"], + "created_at": event["created_at"], + } + ) + return { "generated_at": datetime.utcnow(), "client_rows": client_rows, "user_rows": user_rows, "feature_rows": feature_rows, + "permission_rows": permission_rows, + "audit_rows": audit_rows, "clients": serialized_clients, } + + +def record_audit_event( + db: Session, + *, + tenant_id: str, + client_account_id: int, + actor_type: str, + actor_name: str, + actor_email: str, + actor_role: str, + action: str, + target_type: str, + target_id: int | None, + module_key: str | None, + summary: str, +) -> None: + db.add( + ClientAccessAuditEvent( + tenant_id=tenant_id, + client_account_id=client_account_id, + actor_type=actor_type, + actor_name=actor_name, + actor_email=actor_email, + actor_role=actor_role, + action=action, + target_type=target_type, + target_id=target_id, + module_key=module_key, + summary=summary, + ) + ) diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index 3ddc22e..e2c1806 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -10,11 +10,11 @@ from app.db.migrations import bootstrap_schema, sync_tenant_ids from app.db.session import Base from app.main import app from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule -from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser +from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser from app.models.mix import Mix, MixIngredient from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion -from app.services.client_access_service import build_client_access_export, serialize_client_account +from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost @@ -101,6 +101,8 @@ def test_root_and_login_endpoints(): assert client_login_response.status_code == 200 assert client_login_response.json()["email"] == settings.client_email assert client_login_response.json()["tenant_id"] == settings.client_tenant_id + assert client_login_response.json()["client_role"] == "superadmin" + assert client_login_response.json()["module_permissions"]["client_access"] == "manage" admin_login_response = client.post( "/api/auth/admin/login", @@ -125,7 +127,7 @@ def test_client_access_export_helpers(): ClientUser( full_name="Amelia Hart", email="amelia.hart@specialtyfeeds.example", - role="admin", + role="superadmin", status="active", is_new_user=False, ), @@ -155,6 +157,23 @@ def test_client_access_export_helpers(): ] ) db.add(client) + db.flush() + for user in client.users: + ensure_user_module_permissions(db, user) + client.audit_events.append( + ClientAccessAuditEvent( + tenant_id=client.tenant_id, + actor_type="lean_admin", + actor_name="Lean 101", + actor_email="admin@lean101.local", + actor_role="admin", + action="client_access.seeded", + target_type="client_account", + target_id=client.id, + module_key="client_access", + summary="Initial client access controls were seeded.", + ) + ) db.commit() db.refresh(client) @@ -167,6 +186,8 @@ def test_client_access_export_helpers(): assert export["client_rows"][0]["client_code"] == "SPEC" assert export["user_rows"][0]["client_name"] == "Specialty Feeds" assert len(export["feature_rows"]) == 2 + assert len(export["permission_rows"]) >= 1 + assert len(export["audit_rows"]) == 1 def test_client_access_endpoints(): @@ -181,10 +202,52 @@ def test_client_access_endpoints(): access_response = client.get("/api/client-access", headers=headers) assert access_response.status_code == 200 assert len(access_response.json()) >= 1 + assert "audit_history" in access_response.json()[0] + assert "module_permissions" in access_response.json()[0]["users"][0] export_response = client.get("/api/powerbi/client-access", headers=headers) assert export_response.status_code == 200 assert "client_rows" in export_response.json() + assert "permission_rows" in export_response.json() + + client_login_response = client.post( + "/api/auth/client/login", + json={"email": settings.client_email, "password": settings.client_password}, + ) + client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"} + superadmin_access_response = client.get("/api/client-access", headers=client_headers) + assert superadmin_access_response.status_code == 200 + assert len(superadmin_access_response.json()) == 1 + + +def test_module_permission_blocks_client_module_access(): + with TestClient(app) as client: + admin_login_response = client.post( + "/api/auth/admin/login", + json={"email": settings.admin_email, "password": settings.admin_password}, + ) + admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"} + access_response = client.get("/api/client-access", headers=admin_headers) + first_client = access_response.json()[0] + first_user = first_client["users"][0] + + permission = next( + permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials" + ) + client.patch( + f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}", + json={"access_level": "none"}, + headers=admin_headers, + ) + + client_login_response = client.post( + "/api/auth/client/login", + json={"email": settings.client_email, "password": settings.client_password}, + ) + client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"} + raw_materials_response = client.get("/api/raw-materials", headers=client_headers) + + assert raw_materials_response.status_code == 403 def test_bootstrap_schema_creates_missing_tables_and_patches_legacy_tenant_columns(): diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8914972..a674dc2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -13,6 +13,7 @@ import type { ClientAccessAccount, ClientAccessPowerBiExport, ClientUserCreateInput, + ClientUserModulePermission, ClientUserUpdateInput, LoginResponse, Mix, @@ -30,7 +31,7 @@ import { getStoredAdminSession, getStoredClientSession } from '$lib/session'; const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000'; -type AuthMode = 'none' | 'client' | 'admin'; +type AuthMode = 'none' | 'client' | 'admin' | 'manager'; type ApiFetch = typeof fetch; function getApiBaseUrl() { @@ -63,6 +64,10 @@ function getToken(auth: AuthMode) { return getStoredAdminSession()?.token ?? null; } + if (auth === 'manager') { + return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null; + } + return null; } @@ -127,9 +132,9 @@ export const api = { productCosts: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), scenarios: (fetcher?: ApiFetch) => fetchJson('/api/scenarios', mockScenarios, 'client', fetcher), - clientAccess: (fetcher?: ApiFetch) => fetchJson('/api/client-access', mockClientAccess, 'admin', fetcher), + clientAccess: (fetcher?: ApiFetch) => fetchJson('/api/client-access', mockClientAccess, 'manager', fetcher), clientAccessExport: (fetcher?: ApiFetch) => - fetchJson('/api/powerbi/client-access', mockClientAccessExport, 'admin', fetcher), + fetchJson('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher), dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher), clientLogin: (email: string, password: string) => request('/api/auth/client/login', { @@ -141,6 +146,8 @@ export const api = { method: 'POST', body: JSON.stringify({ email, password }) }), + clientSession: (fetcher?: ApiFetch) => request('/api/auth/client/session', { method: 'GET' }, 'client', fetcher), + adminSession: (fetcher?: ApiFetch) => request('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher), login: (email: string, password: string) => request('/api/auth/client/login', { method: 'POST', @@ -184,15 +191,20 @@ export const api = { request('/api/client-access/users', { method: 'POST', body: JSON.stringify(payload) - }, 'admin'), + }, 'manager'), updateClientUser: (userId: number, payload: ClientUserUpdateInput) => request(`/api/client-access/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) - }, 'admin'), + }, 'manager'), + updateClientUserModulePermission: (userId: number, permission: Pick, payload: { access_level: string }) => + request(`/api/client-access/users/${userId}/module-permissions/${permission.module_key}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'manager'), updateClientFeature: (featureId: number, payload: { enabled: boolean }) => request(`/api/client-access/features/${featureId}`, { method: 'PATCH', body: JSON.stringify(payload) - }, 'admin') + }, 'manager') }; diff --git a/frontend/src/lib/components/ClientAccessWorkspace.svelte b/frontend/src/lib/components/ClientAccessWorkspace.svelte index e231d50..63913df 100644 --- a/frontend/src/lib/components/ClientAccessWorkspace.svelte +++ b/frontend/src/lib/components/ClientAccessWorkspace.svelte @@ -1,6 +1,12 @@

Client Access Control

-

Manage client users, feature flags, and Power BI-ready access data from one admin workspace.

-

The preview stays aligned with the export payload so access changes and reporting stay in sync.

+

Manage module permissions, feature flags, and audit history from one workspace.

+

Lean 101 admins and tenant superadmins use the same control surface, and every change lands in the audit log immediately.

+ Signed in as {accessManagerLabel}
-
-
- Total Clients - {clients.length} -

Accounts currently staged in the client app

-
+{#if !clients.length} +
+

Access Required

+

Client access management is not available for this session.

+

Lean 101 admin access or a tenant superadmin user with Client Access management permission is required.

+
+{:else} +
+
+ Total Clients + {clients.length} +

Accounts currently staged in the client app

+
-
- Total Users - {totalUsers} -

New and existing users across every client

-
+
+ Total Users + {totalUsers} +

New and existing users across every client

+
-
- Enabled Features - {totalEnabledFeatures} -

Feature switches currently turned on

-
-
+
+ Enabled Features + {totalEnabledFeatures} +

Client-level modules currently switched on

+
-
-
-
-
-

Clients

-

Select a client before amending users or feature access.

-
-
+
+ Audit Events + {totalAuditEvents} +

Recorded access changes across visible clients

+
+
-
- {#each clients as client} - - {/each} -
- - -
-
-
-

Selected Client

-

{selectedClient?.name ?? 'No client selected'}

-

{selectedClient?.powerbi_workspace ?? 'No Power BI workspace assigned yet.'}

-
- {#if selectedClient} - {selectedClient.status} - {/if} -
- -
-
- Existing users - {selectedClient ? selectedClient.users.length - selectedClient.new_user_count : 0} -
-
- New users - {selectedClient?.new_user_count ?? 0} -
-
- Enabled features - {selectedClient?.enabled_feature_count ?? 0}/{selectedClient?.total_feature_count ?? 0} -
-
- -
-
-

Add New User

- Creates the user and immediately updates the export preview. -
- -
- - - - - - - -
- -
-
+
+
+
+
+

Power BI Preview

+

Export Shape

+

{previewStatus}

+
+ GET /api/powerbi/client-access +
+ +
+
+ Client rows + {exportPreview.client_rows.length} +
+
+ User rows + {exportPreview.user_rows.length} +
+
+ Feature rows + {exportPreview.feature_rows.length} +
+
+ Permission rows + {exportPreview.permission_rows.length} +
+
+ Audit rows + {exportPreview.audit_rows.length} +
+
+ +
{previewJson}
+
+
+{/if} diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index ba15947..bf92886 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -2,7 +2,7 @@ import { invalidateAll } from '$app/navigation'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { clientSession, sessionHydrated } from '$lib/session'; + import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session'; import { onMount, tick } from 'svelte'; import packageInfo from '../../../package.json'; @@ -18,22 +18,23 @@ label: string; shortLabel: string; icon?: 'home'; + moduleKey?: string; }; - const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home' }; + const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' }; const workingDocumentItems: NavItem[] = [ - { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM' }, - { href: '/mixes', label: 'Mix Master', shortLabel: 'MM' }, - { href: '/products', label: 'Products', shortLabel: 'PR' }, - { href: '/scenarios', label: 'Scenarios', shortLabel: 'SC' } + { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' }, + { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' }, + { href: '/products', label: 'Products', shortLabel: 'PR', moduleKey: 'products' }, + { href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' } ]; - const navigation = [dashboardItem, ...workingDocumentItems]; + const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' }; + const navigation = [dashboardItem, ...workingDocumentItems, accessControlItem]; const footerLinks = [ { href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' }, { href: '/scenarios', label: 'Planning View', shortLabel: 'PV' } ]; - const primaryBottomNavigation = [dashboardItem, ...workingDocumentItems.slice(0, 3)]; const searchItems: SearchItem[] = [ { @@ -88,12 +89,30 @@ let quickMenuOpen = $state(false); let userMenuOpen = $state(false); let navOpen = $state(false); + let workingDocumentsExpanded = $state(true); let showBottomNav = $state(false); let isRestoringSession = $state(false); let restoredToken = $state(null); let paletteInput: HTMLInputElement | null = $state(null); const appVersion = `v${packageInfo.version}`; const currentYear = new Date().getFullYear(); + const visibleDashboardItem = $derived( + !$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null + ); + const visibleWorkingDocumentItems = $derived( + !$clientSession + ? workingDocumentItems + : workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey)) + ); + const visibleFooterLinks = $derived([ + ...footerLinks, + ...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage') + ? [] + : [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }]) + ]); + const primaryBottomNavigation = $derived( + [...(visibleDashboardItem ? [visibleDashboardItem] : []), ...visibleWorkingDocumentItems.slice(0, 3)] + ); function matchesRoute(href: string, pathname: string) { return href === '/' ? pathname === '/' : pathname.startsWith(href); @@ -111,7 +130,8 @@ '/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce', '/products': 'Track delivered product pricing and margin views', '/settings': 'Review your workspace profile and application settings', - '/scenarios': 'Compare alternate pricing and production assumptions' + '/scenarios': 'Compare alternate pricing and production assumptions', + '/client-access': 'Manage user access, module permissions, and audit history' }; return descriptions[pathname] ?? 'Hunter Premium Produce client workspace'; @@ -256,50 +276,63 @@ -