Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements

This commit is contained in:
2026-05-08 00:00:56 +12:00
parent ebee72d4df
commit 1533b5aa9b
29 changed files with 1851 additions and 520 deletions
+177
View File
@@ -0,0 +1,177 @@
"""Internal-user authentication and permission introspection routes.
Frontends should call ``GET /api/access/me`` to discover which permission keys
the current user has, then use those keys to hide/show navigation items.
**Visibility is not security** — every privileged backend route must depend on
``require_permission`` (or one of its siblings) directly.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.access import (
INTERNAL_USER_SUBJECT,
INTERNAL_USER_TENANT_ID,
get_current_user,
get_user_permissions,
permissions_to_module_map,
require_permission,
)
from app.core.config import settings
from app.core.security import issue_token
from app.db.session import get_db
from app.models.access import Permission, Role, User
router = APIRouter(prefix="/api/access", tags=["access"])
class LoginRequest(BaseModel):
email: str
password: str
class UserSession(BaseModel):
# Mirrors the existing `LoginResponse` shape so the frontend's `AppSession`
# store can consume this response without a separate type. `permissions`
# is the new permission-key array; `module_permissions` is the legacy
# module→access-level map for nav gating.
user_id: int
email: str
name: str
role: str
role_name: str | None = None
is_active: bool
tenant_id: str | None = None
client_role: str | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] = {}
permissions: list[str]
token: str | None = None
class RoleRead(BaseModel):
id: int
name: str
description: str | None
permissions: list[str]
class UserRead(BaseModel):
id: int
email: str
name: str
is_active: bool
role: str | None
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
permission_set = get_user_permissions(user)
permissions = sorted(permission_set)
module_permissions = permissions_to_module_map(permission_set)
role_name = user.role.name if user.role else None
token = None
if include_token:
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
# role="internal" is a marker the shared auth deps recognise so internal
# users can hit the same routes as client-portal users without being
# confused with them. Display name lives in role_name / client_role.
return UserSession(
user_id=user.id,
email=user.email,
name=user.name,
role="internal",
role_name=role_name,
is_active=user.is_active,
tenant_id=INTERNAL_USER_TENANT_ID,
client_role=role_name,
client_account_id=None,
module_permissions=module_permissions,
permissions=permissions,
token=token,
)
@router.post("/login", response_model=UserSession)
def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""Internal-user login.
Authenticates against a shared internal password (``ADMIN_PASSWORD``) and
looks up the user by email. Inactive or unknown users are rejected with
a generic 401 to avoid leaking which emails are valid.
"""
if payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
email = payload.email.strip().lower()
user = db.scalar(
select(User)
.where(User.email == email)
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return _serialize_session(user, include_token=True)
@router.get("/me", response_model=UserSession)
def read_me(user: User = Depends(get_current_user)):
"""Return the current user with permission keys for UI navigation gating."""
return _serialize_session(user)
@router.get("/me/permissions", response_model=list[str])
def read_my_permissions(user: User = Depends(get_current_user)):
return sorted(get_user_permissions(user))
# Permission-enforced administrative endpoints. Route bodies should not check
# role names — every gate is the require_permission(...) dependency.
@router.get("/users", response_model=list[UserRead])
def list_users(
db: Session = Depends(get_db),
_: User = Depends(require_permission("view_users")), # gated by permission key
):
users = db.scalars(select(User).options(selectinload(User.role))).all()
return [
UserRead(
id=user.id,
email=user.email,
name=user.name,
is_active=user.is_active,
role=user.role.name if user.role else None,
)
for user in users
]
@router.get("/roles", response_model=list[RoleRead])
def list_roles(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
):
roles = db.scalars(
select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
).all()
return [
RoleRead(
id=role.id,
name=role.name,
description=role.description,
permissions=sorted(p.key for p in role.permissions),
)
for role in roles
]
@router.get("/permissions", response_model=list[str])
def list_permissions(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
):
return sorted(p.key for p in db.scalars(select(Permission)).all())
+150
View File
@@ -0,0 +1,150 @@
"""Dashboard summary endpoint.
Returns only the aggregates the homepage actually renders — counts, top items,
totals, and a trend-chart series. Replaces a Dashboard load that previously
fetched five full collections (raw materials, mixes, all product cost
breakdowns, scenarios, data-quality) and only used summaries from each.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.models.raw_material import RawMaterial
from app.services.client_access_service import has_access_level
from app.services.costing_engine import (
calculate_mix_cost,
calculate_product_cost,
get_active_price,
calculate_raw_material_cost,
)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _can(session: AuthSession, module_key: str) -> bool:
permissions = session.module_permissions or {}
return has_access_level(permissions.get(module_key), "view")
@router.get("/summary")
def dashboard_summary(
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
raw_materials_summary: dict | None = None
mixes_summary: dict | None = None
products_summary: dict | None = None
raw_series: list[float] = []
mix_series: list[float] = []
product_series: list[float] = []
if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
).all()
total_market_value = 0.0
latest = None
latest_date = None
for material in materials:
active = get_active_price(material)
if active is None:
continue
comp = calculate_raw_material_cost(material, active)
raw_series.append(comp.cost_per_kg)
total_market_value += active.market_value
if latest_date is None or active.effective_date > latest_date:
latest_date = active.effective_date
latest = {
"id": material.id,
"name": material.name,
"market_value": active.market_value,
"cost_per_kg": comp.cost_per_kg,
"effective_date": active.effective_date.isoformat() if active.effective_date else None,
}
raw_materials_summary = {
"count": len(materials),
"total_market_value": round(total_market_value, 4),
"latest": latest,
}
if _can(session, "mix_master") or _can(session, "dashboard"):
mix_rows = db.scalars(
select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)
).all()
cost_sum = 0.0
cost_count = 0
top_mix: dict | None = None
for mix in mix_rows:
result = calculate_mix_cost(db, mix.id)
cost_per_kg = result.get("mix_cost_per_kg")
if cost_per_kg is not None:
mix_series.append(cost_per_kg)
cost_sum += cost_per_kg
cost_count += 1
if top_mix is None or (cost_per_kg or 0) > (top_mix.get("mix_cost_per_kg") or 0):
top_mix = {
"id": result["id"],
"name": result["name"],
"client_name": result["client_name"],
"ingredients_count": len(result["ingredients"]),
"total_mix_kg": result["total_mix_kg"],
"total_mix_cost": result["total_mix_cost"],
"mix_cost_per_kg": cost_per_kg,
"warnings": result["warnings"],
}
mixes_summary = {
"count": len(mix_rows),
"average_cost_per_kg": round(cost_sum / cost_count, 4) if cost_count else 0.0,
"top": top_mix,
}
if _can(session, "products") or _can(session, "dashboard"):
products = db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id)
).all()
rows: list[dict] = []
for product in products:
result = calculate_product_cost(db, product.id)
finished = result.get("finished_product_delivered") or 0.0
product_series.append(finished)
rows.append(
{
"id": product.id,
"product_name": result["product_name"],
"client_name": result["client_name"],
"finished_product_delivered": finished,
"warnings": result["warnings"],
}
)
rows.sort(key=lambda row: row["finished_product_delivered"], reverse=True)
products_summary = {
"count": len(products),
"top": rows[0] if rows else None,
"top_products": rows[:4],
}
return {
"raw_materials": raw_materials_summary,
"mixes": mixes_summary,
"products": products_summary,
# Pre-computed numeric series for the homepage trend chart so the
# client doesn't need full collections to draw it.
"trend_seeds": {
"raw_material_cost_per_kg": raw_series,
"mix_cost_per_kg": mix_series,
"product_finished_delivered": product_series,
},
}
+68 -1
View File
@@ -7,8 +7,15 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.access import (
INTERNAL_USER_SUBJECT,
INTERNAL_USER_TENANT_ID,
get_user_permissions,
permissions_to_module_map,
)
from app.core.security import verify_token
from app.db.session import get_db
from app.models.access import Role, User
from app.models.client_access import ClientFeatureAccess, ClientUser
from app.services.client_access_service import has_access_level, module_access_map
@@ -27,11 +34,52 @@ class AuthSession:
module_permissions: dict[str, str] | None = None
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
"""Translate an internal-user token into an AuthSession the shared route
dependencies can consume. Internal users present `role="internal"` so
`require_client_module_access` can spot them and skip the ClientUser DB
lookup, deriving their module_permissions from the role-permission table.
"""
user_id = payload.get("user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
user = db.scalar(
select(User)
.where(User.id == user_id)
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive")
return AuthSession(
role="internal",
email=user.email,
name=user.name,
tenant_id=INTERNAL_USER_TENANT_ID,
client_role=user.role.name if user.role else None,
user_id=user.id,
client_account_id=None,
module_permissions=permissions_to_module_map(get_user_permissions(user)),
)
def get_auth_session(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> AuthSession:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
# Internal Hunter Stock Feeds users get an auth session derived from the
# role/permission tables rather than the client-portal ClientUser tables.
if payload.get("sub") == INTERNAL_USER_SUBJECT:
return _build_internal_auth_session(db, payload)
return AuthSession(
role=str(payload.get("role", "")),
email=str(payload.get("email", "")),
@@ -45,6 +93,13 @@ def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(
def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
# Internal Hunter Stock Feeds users share the workspace with client users
# but don't have a ClientUser row, so we accept them here and let
# `require_client_module_access` enforce the per-module checks.
if session.role == "internal":
if not session.tenant_id or not session.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Internal user context is missing")
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
if not session.tenant_id:
@@ -82,6 +137,18 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
) -> AuthSession:
# Internal users have their permissions baked into the AuthSession at
# token-resolve time; skip the ClientUser/feature DB lookups and check
# the in-memory module_permissions map directly.
if session.role == "internal":
permissions = session.module_permissions or {}
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 session
user = load_current_client_user(db, session)
feature = db.scalar(
select(ClientFeatureAccess).where(