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
+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(