Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements
This commit is contained in:
@@ -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())
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user