This commit is contained in:
2026-05-10 09:46:07 +12:00
parent cfc193b713
commit 2f2466ecac
81 changed files with 2571 additions and 413 deletions
+30 -6
View File
@@ -7,7 +7,7 @@ the current user has, then use those keys to hide/show navigation items.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -21,12 +21,19 @@ from app.core.access import (
require_permission,
)
from app.core.config import settings
from app.core.http import CLIENT_AUTH_COOKIE
from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key
from app.core.security_logging import log_security_event
from app.core.security import hash_password, issue_token, verify_password
from app.db.session import get_db
from app.models.access import Permission, Role, User
router = APIRouter(prefix="/api/access", tags=["access"])
login_rate_limiter = SlidingWindowRateLimiter(
limit=settings.login_rate_limit_attempts,
window_seconds=settings.login_rate_limit_window_seconds,
)
class LoginRequest(BaseModel):
@@ -75,7 +82,10 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
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})
token = issue_token(
{"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email},
ttl_seconds=settings.session_ttl_seconds,
)
# 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.
@@ -96,14 +106,16 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
@router.post("/login", response_model=UserSession)
def login(payload: LoginRequest, db: Session = Depends(get_db)):
def login(payload: LoginRequest, response: Response, request: Request, 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.
"""
login_rate_limiter.hit(request_client_key(request, suffix="internal-login"))
if payload.password != settings.admin_password:
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
email = payload.email.strip().lower()
@@ -113,15 +125,20 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None or not user.is_active:
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return _serialize_session(user, include_token=True)
session = _serialize_session(user, include_token=True)
if session.token:
CLIENT_AUTH_COOKIE.apply(response, session.token)
log_security_event("auth.login_succeeded", audience="internal", role=user.role.name if user.role else None, user_id=user.id)
return session.model_copy(update={"token": None})
@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)
return _serialize_session(user).model_copy(update={"token": None})
@router.get("/me/permissions", response_model=list[str])
@@ -181,7 +198,14 @@ def update_me(
db.commit()
db.refresh(user)
return _serialize_session(user, include_token=True)
return _serialize_session(user, include_token=True).model_copy(update={"token": None})
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response):
CLIENT_AUTH_COOKIE.clear(response)
response.status_code = status.HTTP_204_NO_CONTENT
return None
# Permission-enforced administrative endpoints. Route bodies should not check
+44 -9
View File
@@ -1,16 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_admin_session, require_client_session
from app.core.config import settings
from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE
from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key
from app.core.security_logging import log_security_event
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"])
login_rate_limiter = SlidingWindowRateLimiter(
limit=settings.login_rate_limit_attempts,
window_seconds=settings.login_rate_limit_window_seconds,
)
class LoginRequest(BaseModel):
@@ -27,7 +34,7 @@ class SessionResponse(BaseModel):
user_id: int | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] = Field(default_factory=dict)
token: str
token: str | None = None
def _build_session_response(
@@ -50,7 +57,8 @@ def _build_session_response(
"client_role": client_role,
"user_id": user_id,
"client_account_id": client_account_id,
}
},
ttl_seconds=settings.session_ttl_seconds,
)
return SessionResponse(
name=name,
@@ -66,19 +74,22 @@ def _build_session_response(
@router.post("/client/login", response_model=SessionResponse)
def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
def client_login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
login_rate_limiter.hit(request_client_key(request, suffix="client-login"))
if payload.password != settings.client_password:
log_security_event("auth.login_failed", audience="client", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
user = get_client_user_by_email(db, email=payload.email.strip().lower())
if user is None:
log_security_event("auth.login_failed", audience="client", ip=request_client_key(request))
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 for this user")
return _build_session_response(
session_response = _build_session_response(
name=user.full_name,
email=user.email,
role="client",
@@ -88,14 +99,24 @@ def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
client_account_id=client_account.id,
module_permissions=module_access_map(user),
)
if session_response.token:
CLIENT_AUTH_COOKIE.apply(response, session_response.token)
log_security_event("auth.login_succeeded", audience="client", role="client", user_id=user.id, tenant_id=client_account.tenant_id)
return session_response.model_copy(update={"token": None})
@router.post("/admin/login", response_model=SessionResponse)
def admin_login(payload: LoginRequest):
def admin_login(payload: LoginRequest, response: Response, request: Request):
login_rate_limiter.hit(request_client_key(request, suffix="admin-login"))
if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password:
log_security_event("auth.login_failed", audience="admin", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin email or password")
return _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
session_response = _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
if session_response.token:
ADMIN_AUTH_COOKIE.apply(response, session_response.token)
log_security_event("auth.login_succeeded", audience="admin", role="admin")
return session_response.model_copy(update={"token": None})
@router.get("/client/session", response_model=SessionResponse)
@@ -112,9 +133,23 @@ def read_client_session(session: AuthSession = Depends(require_client_session),
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=module_access_map(user),
)
).model_copy(update={"token": None})
@router.get("/admin/session", response_model=SessionResponse)
def read_admin_session(session: AuthSession = Depends(require_admin_session)):
return _build_session_response(name=session.name, email=session.email, role=session.role)
return _build_session_response(name=session.name, email=session.email, role=session.role).model_copy(update={"token": None})
@router.post("/client/logout", status_code=status.HTTP_204_NO_CONTENT)
def client_logout(response: Response):
CLIENT_AUTH_COOKIE.clear(response)
response.status_code = status.HTTP_204_NO_CONTENT
return None
@router.post("/admin/logout", status_code=status.HTTP_204_NO_CONTENT)
def admin_logout(response: Response):
ADMIN_AUTH_COOKIE.clear(response)
response.status_code = status.HTTP_204_NO_CONTENT
return None
+3 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
@@ -78,10 +78,11 @@ def _actor_metadata(session: AuthSession) -> dict[str, str]:
@router.get("", response_model=list[ClientAccessRead])
def get_client_access(
limit: int = Query(default=100, ge=1, le=200),
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)]
return [serialize_client_account(client) for client in _authorized_client_scope(db, session)[:limit]]
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
+2 -2
View File
@@ -11,7 +11,7 @@ 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.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
@@ -35,7 +35,7 @@ def _can(session: AuthSession, module_key: str) -> bool:
@router.get("/summary")
def dashboard_summary(
session: AuthSession = Depends(require_client_session),
session: AuthSession = Depends(require_client_module_access("dashboard")),
db: Session = Depends(get_db),
):
raw_materials_summary: dict | None = None
+14 -7
View File
@@ -2,8 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -13,14 +12,14 @@ from app.core.access import (
get_user_permissions,
permissions_to_module_map,
)
from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token
from app.core.security_logging import log_security_event
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
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(frozen=True)
class AuthSession:
@@ -67,13 +66,16 @@ def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
def get_auth_session(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
request: Request,
db: Session = Depends(get_db),
) -> AuthSession:
if credentials is None:
token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name) or get_bearer_or_cookie_token(
request, cookie_name=ADMIN_AUTH_COOKIE.name
)
if token is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
payload = verify_token(token)
# Internal Hunter Stock Feeds users get an auth session derived from the
# role/permission tables rather than the client-portal ClientUser tables.
@@ -111,6 +113,7 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) ->
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
if session.role != "admin":
log_security_event("authz.denied", role=session.role, required="admin")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session
@@ -143,6 +146,7 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get(module_key), minimum_level):
log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{module_key} access is not permitted",
@@ -158,10 +162,12 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
)
)
if feature is not None and not feature.enabled:
log_security_event("authz.denied", role=session.role, module=module_key, reason="feature_disabled")
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):
log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted")
return AuthSession(
@@ -190,6 +196,7 @@ def require_client_access_manager_session(
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"):
log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required")
return AuthSession(
+3 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
@@ -37,10 +37,11 @@ def mix_calculator_options(
@router.get("", response_model=list[MixCalculatorSessionSummaryRead])
def mix_calculator_sessions(
limit: int = Query(default=100, ge=1, le=200),
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
db: Session = Depends(get_db),
):
return list_mix_calculator_sessions(db, auth_session=session)
return list_mix_calculator_sessions(db, auth_session=session, limit=limit)
@router.post("/preview", response_model=MixCalculatorPreviewRead)
+7 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -13,8 +13,12 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
@router.get("", response_model=list[MixRead])
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()
def list_mixes(
limit: int = Query(default=100, ge=1, le=200),
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).limit(limit)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes]
+8 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -23,6 +23,7 @@ def _serialize_product(product: Product) -> dict:
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"own_bag": product.own_bag,
"visible": product.visible,
"unit_of_measure": product.unit_of_measure,
"items_per_pallet": product.items_per_pallet,
"bagging_process": product.bagging_process,
@@ -34,8 +35,12 @@ def _serialize_product(product: Product) -> dict:
@router.get("", response_model=list[ProductRead])
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()
def list_products(
limit: int = Query(default=100, ge=1, le=200),
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).limit(limit)).all()
return [_serialize_product(product) for product in products]
+14 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -34,12 +34,17 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
@router.get("", response_model=list[RawMaterialRead])
def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
def list_raw_materials(
limit: int = Query(default=100, ge=1, le=200),
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)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
.limit(limit)
).all()
return [serialize_raw_material(material) for material in materials]
@@ -130,7 +135,12 @@ 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_module_access("raw_materials")), db: Session = Depends(get_db)):
def get_price_history(
raw_material_id: int,
limit: int = Query(default=100, ge=1, le=200),
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")
@@ -141,6 +151,7 @@ def get_price_history(raw_material_id: int, session: AuthSession = Depends(requi
RawMaterialPriceVersion.tenant_id == session.tenant_id,
)
.order_by(RawMaterialPriceVersion.effective_date.desc())
.limit(limit)
).all()
items = []
for price in prices:
+7 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -12,8 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@router.get("", response_model=list[ScenarioRead])
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()
def list_scenarios(
limit: int = Query(default=100, ge=1, le=200),
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()).limit(limit)).all()
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)