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(
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Database-driven role/permission access control.
|
||||
|
||||
This module is the single source of truth for permission enforcement on
|
||||
internal Hunter Stock Feeds users. Route handlers should depend on
|
||||
``require_permission`` / ``require_any_permission`` / ``require_all_permissions``
|
||||
rather than checking role names. To change access later, update the database
|
||||
seed (``app.seed_access``) — no route code should need to change.
|
||||
|
||||
Fail-closed rules enforced here:
|
||||
* Missing or invalid token -> 401
|
||||
* Unknown user_id -> 401
|
||||
* Inactive user -> 403
|
||||
* No role / missing perm -> 403
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
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.access import Permission, Role, User
|
||||
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
# Subject claim used by tokens issued for internal Hunter Stock Feeds users.
|
||||
# Distinct from the existing client-portal/admin tokens so the two systems
|
||||
# cannot impersonate each other.
|
||||
INTERNAL_USER_SUBJECT = "internal_user"
|
||||
|
||||
# Default tenant internal Hunter Stock Feeds users operate against. Internal
|
||||
# users see the production workspace data, so they read/write the same tenant
|
||||
# the costing seed populates.
|
||||
INTERNAL_USER_TENANT_ID = "hunter-premium-produce"
|
||||
|
||||
# Maps the internal permission keys onto the legacy module/access-level shape
|
||||
# the existing client-portal routes already understand. This lets the shared
|
||||
# routes (raw materials, mixes, products, etc.) accept either token type
|
||||
# without having to be rewritten.
|
||||
_PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
|
||||
"view_dashboard": ("dashboard", "view"),
|
||||
"view_mix_calculator": ("mix_calculator", "view"),
|
||||
"use_mix_calculator": ("mix_calculator", "edit"),
|
||||
"save_mix_calculator_session": ("mix_calculator", "edit"),
|
||||
"view_raw_materials": ("raw_materials", "view"),
|
||||
"edit_raw_materials": ("raw_materials", "edit"),
|
||||
"view_products": ("products", "view"),
|
||||
"edit_products": ("products", "edit"),
|
||||
"view_mixes": ("mix_master", "view"),
|
||||
"edit_mixes": ("mix_master", "edit"),
|
||||
# Admin-only permissions (view_users, manage_users, manage_permissions,
|
||||
# view_settings, edit_settings) are intentionally excluded — they don't
|
||||
# correspond to any of the legacy module keys and remain accessible only
|
||||
# via the explicit `require_permission(...)` dependency.
|
||||
}
|
||||
|
||||
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
|
||||
|
||||
|
||||
def permissions_to_module_map(permission_keys: set[str]) -> dict[str, str]:
|
||||
"""Translate internal permission keys into the legacy module→level map.
|
||||
|
||||
Higher levels (edit > view) win when multiple permissions map to the same
|
||||
module key (e.g. ``edit_raw_materials`` overrides ``view_raw_materials``).
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for key in permission_keys:
|
||||
mapping = _PERMISSION_TO_MODULE_LEVEL.get(key)
|
||||
if not mapping:
|
||||
continue
|
||||
module_key, level = mapping
|
||||
if _ACCESS_LEVEL_RANK[level] > _ACCESS_LEVEL_RANK.get(result.get(module_key, "none"), 0):
|
||||
result[module_key] = level
|
||||
return result
|
||||
|
||||
|
||||
def get_user_permissions(user: User | None) -> set[str]:
|
||||
"""Return the set of permission keys granted to ``user``.
|
||||
|
||||
Returns an empty set for unknown users, inactive users, or users without
|
||||
a role. This means downstream checks fail closed by default.
|
||||
"""
|
||||
if user is None or not user.is_active or user.role is None:
|
||||
return set()
|
||||
return {permission.key for permission in user.role.permissions}
|
||||
|
||||
|
||||
def user_has_permission(user: User | None, permission_key: str) -> bool:
|
||||
return permission_key in get_user_permissions(user)
|
||||
|
||||
|
||||
def _load_user(db: Session, user_id: int) -> User | None:
|
||||
return db.scalar(
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(selectinload(User.role).selectinload(Role.permissions))
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""Resolve the current internal user from the bearer token.
|
||||
|
||||
Raises 401 for missing/invalid tokens or unknown users, 403 for inactive
|
||||
users.
|
||||
"""
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if payload.get("sub") != INTERNAL_USER_SUBJECT:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
|
||||
|
||||
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 = _load_user(db, user_id)
|
||||
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 user
|
||||
|
||||
|
||||
def require_permission(permission_key: str):
|
||||
"""FastAPI dependency factory enforcing a single permission key."""
|
||||
|
||||
def dependency(user: User = Depends(get_current_user)) -> User:
|
||||
if not user_has_permission(user, permission_key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permission: {permission_key}",
|
||||
)
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_any_permission(permission_keys: Iterable[str]):
|
||||
"""FastAPI dependency factory: pass if the user has any of the keys."""
|
||||
keys = tuple(permission_keys)
|
||||
|
||||
def dependency(user: User = Depends(get_current_user)) -> User:
|
||||
granted = get_user_permissions(user)
|
||||
if not any(key in granted for key in keys):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires any of: {list(keys)}",
|
||||
)
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_all_permissions(permission_keys: Iterable[str]):
|
||||
"""FastAPI dependency factory: pass only if the user has every key."""
|
||||
keys = tuple(permission_keys)
|
||||
|
||||
def dependency(user: User = Depends(get_current_user)) -> User:
|
||||
granted = get_user_permissions(user)
|
||||
missing = [key for key in keys if key not in granted]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permissions: {missing}",
|
||||
)
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INTERNAL_USER_SUBJECT",
|
||||
"Permission",
|
||||
"Role",
|
||||
"User",
|
||||
"bearer_scheme",
|
||||
"get_current_user",
|
||||
"get_user_permissions",
|
||||
"require_all_permissions",
|
||||
"require_any_permission",
|
||||
"require_permission",
|
||||
"user_has_permission",
|
||||
]
|
||||
@@ -48,3 +48,41 @@ def verify_token(token: str) -> dict[str, Any]:
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired")
|
||||
return payload
|
||||
|
||||
|
||||
# --- Password hashing ------------------------------------------------------
|
||||
# PBKDF2-SHA256 with a per-password 16-byte salt and 200k iterations. Stored
|
||||
# as `pbkdf2_sha256$iterations$salt_hex$hash_hex`. No external dep needed.
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
_PBKDF2_ITERATIONS = 200_000
|
||||
_PBKDF2_ALGO = "pbkdf2_sha256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
if not password:
|
||||
raise ValueError("Password cannot be empty")
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS)
|
||||
return f"{_PBKDF2_ALGO}${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str | None) -> bool:
|
||||
if not encoded or not password:
|
||||
return False
|
||||
try:
|
||||
algo, iterations_str, salt_hex, digest_hex = encoded.split("$", 3)
|
||||
except ValueError:
|
||||
return False
|
||||
if algo != _PBKDF2_ALGO:
|
||||
return False
|
||||
try:
|
||||
iterations = int(iterations_str)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(digest_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||
return hmac.compare_digest(candidate, expected)
|
||||
|
||||
@@ -12,8 +12,10 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
|
||||
from app.api.access import router as access_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.client_access import router as client_access_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.mix_calculator import router as mix_calculator_router
|
||||
from app.api.mixes import router as mixes_router
|
||||
from app.api.powerbi import router as powerbi_router
|
||||
@@ -72,7 +74,9 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(access_router)
|
||||
app.include_router(client_access_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(raw_materials_router)
|
||||
app.include_router(mixes_router)
|
||||
app.include_router(mix_calculator_router)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.models.access import Permission, Role, User, role_permissions
|
||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
@@ -19,9 +20,13 @@ __all__ = [
|
||||
"MixCalculatorSessionLine",
|
||||
"MixIngredient",
|
||||
"PackagingCostRule",
|
||||
"Permission",
|
||||
"ProcessCostRule",
|
||||
"Product",
|
||||
"RawMaterial",
|
||||
"RawMaterialPriceVersion",
|
||||
"Role",
|
||||
"Scenario",
|
||||
"User",
|
||||
"role_permissions",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
permissions: Mapped[list["Permission"]] = relationship(
|
||||
secondary=role_permissions,
|
||||
back_populates="roles",
|
||||
lazy="selectin",
|
||||
)
|
||||
users: Mapped[list["User"]] = relationship(back_populates="role")
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
roles: Mapped[list["Role"]] = relationship(
|
||||
secondary=role_permissions,
|
||||
back_populates="permissions",
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
role_id: Mapped[int | None] = mapped_column(ForeignKey("roles.id"), nullable=True, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# Per-user password hash (PBKDF2-SHA256). Null while a user has never set
|
||||
# a personal password — they can still sign in with the shared internal
|
||||
# password until they choose one in settings.
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
role: Mapped["Role | None"] = relationship(back_populates="users", lazy="selectin")
|
||||
@@ -15,6 +15,7 @@ from app.models.client_access import ClientAccessAuditEvent, ClientAccount, Clie
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product
|
||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||
from app.seed_access import seed_access
|
||||
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
|
||||
|
||||
|
||||
@@ -688,6 +689,7 @@ def seed_if_empty():
|
||||
else:
|
||||
logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH)
|
||||
seed_client_access(db)
|
||||
seed_access(db)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Idempotent seed for roles, permissions, and the Hunter Stock Feeds users.
|
||||
|
||||
Re-running this is safe: it upserts permissions, syncs each role's permission
|
||||
set to the declared list, and creates or updates the seed users without
|
||||
duplicating rows. Permission grants are the source of truth — change them
|
||||
here (or in the DB) rather than in route code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.access import Permission, Role, User
|
||||
|
||||
|
||||
PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
|
||||
("view_dashboard", "View the operational dashboard"),
|
||||
("view_mix_calculator", "View the mix calculator area"),
|
||||
("use_mix_calculator", "Run calculations in the mix calculator"),
|
||||
("save_mix_calculator_session", "Save mix calculator sessions"),
|
||||
("view_raw_materials", "View raw materials"),
|
||||
("edit_raw_materials", "Create and edit raw materials"),
|
||||
("view_products", "View finished products"),
|
||||
("edit_products", "Create and edit finished products"),
|
||||
("view_mixes", "View mix master recipes"),
|
||||
("edit_mixes", "Create and edit mix master recipes"),
|
||||
("view_users", "View internal users and roles"),
|
||||
("manage_users", "Create, deactivate, and assign user roles"),
|
||||
("manage_permissions", "Modify roles and role-permission assignments"),
|
||||
("view_settings", "View system settings"),
|
||||
("edit_settings", "Edit system settings"),
|
||||
)
|
||||
|
||||
|
||||
ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"Admin": {
|
||||
"description": "Full administrative access including user and permission management.",
|
||||
"permissions": [
|
||||
"view_dashboard",
|
||||
"view_mix_calculator",
|
||||
"use_mix_calculator",
|
||||
"save_mix_calculator_session",
|
||||
"view_raw_materials",
|
||||
"edit_raw_materials",
|
||||
"view_products",
|
||||
"view_mixes",
|
||||
"view_users",
|
||||
"manage_users",
|
||||
"manage_permissions",
|
||||
"view_settings",
|
||||
"edit_settings",
|
||||
],
|
||||
},
|
||||
"Operations": {
|
||||
"description": "Mix calculator only — cannot edit raw materials, products, mixes, users, or settings.",
|
||||
"permissions": [
|
||||
"view_mix_calculator",
|
||||
"use_mix_calculator",
|
||||
"save_mix_calculator_session",
|
||||
],
|
||||
},
|
||||
"Full Access": {
|
||||
"description": "Operational data editor — cannot manage users or permissions unless explicitly granted.",
|
||||
"permissions": [
|
||||
"view_dashboard",
|
||||
"view_mix_calculator",
|
||||
"use_mix_calculator",
|
||||
"save_mix_calculator_session",
|
||||
"view_raw_materials",
|
||||
"edit_raw_materials",
|
||||
"view_products",
|
||||
"edit_products",
|
||||
"view_mixes",
|
||||
"edit_mixes",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SEED_USERS: tuple[dict, ...] = (
|
||||
{
|
||||
"email": "admin@hunterstockfeeds.com",
|
||||
"name": "Hunter Stock Feeds Admin",
|
||||
"role": "Admin",
|
||||
},
|
||||
{
|
||||
"email": "ops@hunterstockfeeds.com",
|
||||
"name": "Hunter Stock Feeds Operations",
|
||||
"role": "Operations",
|
||||
},
|
||||
{
|
||||
"email": "craig@hunterstockfeeds.com",
|
||||
"name": "Craig",
|
||||
"role": "Full Access",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _upsert_permissions(db: Session) -> dict[str, Permission]:
|
||||
existing = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
|
||||
for key, description in PERMISSION_DEFINITIONS:
|
||||
permission = existing.get(key)
|
||||
if permission is None:
|
||||
permission = Permission(key=key, description=description)
|
||||
db.add(permission)
|
||||
existing[key] = permission
|
||||
elif permission.description != description:
|
||||
permission.description = description
|
||||
db.flush()
|
||||
return existing
|
||||
|
||||
|
||||
def _upsert_roles(db: Session, permissions_by_key: dict[str, Permission]) -> dict[str, Role]:
|
||||
existing = {
|
||||
role.name: role
|
||||
for role in db.scalars(
|
||||
select(Role).options(selectinload(Role.permissions))
|
||||
).all()
|
||||
}
|
||||
|
||||
for role_name, definition in ROLE_DEFINITIONS.items():
|
||||
role = existing.get(role_name)
|
||||
if role is None:
|
||||
role = Role(name=role_name, description=definition["description"])
|
||||
db.add(role)
|
||||
existing[role_name] = role
|
||||
elif role.description != definition["description"]:
|
||||
role.description = definition["description"]
|
||||
db.flush()
|
||||
|
||||
desired = {permissions_by_key[key] for key in definition["permissions"]}
|
||||
current = set(role.permissions)
|
||||
for permission in desired - current:
|
||||
role.permissions.append(permission)
|
||||
for permission in current - desired:
|
||||
role.permissions.remove(permission)
|
||||
|
||||
db.flush()
|
||||
return existing
|
||||
|
||||
|
||||
def _upsert_users(db: Session, roles_by_name: dict[str, Role]) -> None:
|
||||
existing = {user.email: user for user in db.scalars(select(User)).all()}
|
||||
for entry in SEED_USERS:
|
||||
email = entry["email"].lower()
|
||||
role = roles_by_name[entry["role"]]
|
||||
user = existing.get(email)
|
||||
if user is None:
|
||||
user = User(email=email, name=entry["name"], role_id=role.id, is_active=True)
|
||||
db.add(user)
|
||||
existing[email] = user
|
||||
else:
|
||||
user.name = entry["name"]
|
||||
user.role_id = role.id
|
||||
if not user.is_active:
|
||||
user.is_active = True
|
||||
db.flush()
|
||||
|
||||
|
||||
def seed_access(db: Session) -> None:
|
||||
"""Idempotent: roles, permissions, role-permission links, seed users."""
|
||||
permissions_by_key = _upsert_permissions(db)
|
||||
roles_by_name = _upsert_roles(db, permissions_by_key)
|
||||
_upsert_users(db, roles_by_name)
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
@@ -108,29 +108,39 @@ def calculate_mix_calculator_preview(
|
||||
|
||||
|
||||
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
# Aggregate mix totals in a single query instead of loading every
|
||||
# ingredient row for every product. The previous implementation was the
|
||||
# main slow path on first Mix Calculator open — it streamed the entire
|
||||
# tenant's recipe table just to compute one sum per product.
|
||||
mix_totals_rows = db.execute(
|
||||
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
|
||||
.join(Mix, Mix.id == MixIngredient.mix_id)
|
||||
.where(Mix.tenant_id == tenant_id)
|
||||
.group_by(MixIngredient.mix_id)
|
||||
).all()
|
||||
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
|
||||
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.tenant_id == tenant_id)
|
||||
.options(selectinload(Product.mix).selectinload(Mix.ingredients))
|
||||
.options(joinedload(Product.mix))
|
||||
.order_by(Product.client_name, Product.name)
|
||||
).all()
|
||||
|
||||
product_rows = []
|
||||
clients = sorted({product.client_name for product in products})
|
||||
for product in products:
|
||||
mix_total_kg = round(sum(ingredient.quantity_kg for ingredient in (product.mix.ingredients if product.mix else [])), 4)
|
||||
product_rows.append(
|
||||
{
|
||||
"product_id": product.id,
|
||||
"client_name": product.client_name,
|
||||
"product_name": product.name,
|
||||
"mix_id": product.mix_id,
|
||||
"mix_name": product.mix.name if product.mix else "",
|
||||
"unit_of_measure": product.unit_of_measure,
|
||||
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
|
||||
"mix_total_kg": mix_total_kg,
|
||||
}
|
||||
)
|
||||
product_rows = [
|
||||
{
|
||||
"product_id": product.id,
|
||||
"client_name": product.client_name,
|
||||
"product_name": product.name,
|
||||
"mix_id": product.mix_id,
|
||||
"mix_name": product.mix.name if product.mix else "",
|
||||
"unit_of_measure": product.unit_of_measure,
|
||||
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
|
||||
"mix_total_kg": mix_totals.get(product.mix_id, 0.0),
|
||||
}
|
||||
for product in products
|
||||
]
|
||||
|
||||
return {"clients": clients, "products": product_rows}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user