Coverage for app/utils/permissions.py: 100.00%
40 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""
2Permission and authorization utilities for role-based access control.
4Provides a ``require_roles()`` FastAPI-dependency factory and the
5``ADMIN_ROLES`` / ``STAFF_ROLES`` groupings. Fine-grained,
6resource-level decisions live in :mod:`app.utils.capabilities`.
7"""
8from collections.abc import Callable
10from fastapi import Depends, HTTPException, status
11from sqlalchemy.orm import Session
13from app.models import (
14 Deployment,
15 Team,
16 User,
17 UserRole,
18 UserToDeployment,
19 UserToTeam,
20)
21from app.utils.keycloak_auth import get_current_user_keycloak as get_current_user
23# ----------------------------------------------------------------
24# ROLE GROUPINGS
25# ----------------------------------------------------------------
26# ``STAFF_ROLES`` covers everyone with elevated privileges (the users
27# for whom the UI shows staff-only chrome). It does NOT mean "anyone
28# with course-teacher rights" — that is a per-resource check handled
29# in :mod:`app.utils.capabilities`.
30STAFF_ROLES: tuple[UserRole, ...] = (UserRole.TEACHER, UserRole.ADMIN)
31ADMIN_ROLES: tuple[UserRole, ...] = (UserRole.ADMIN,)
34# ----------------------------------------------------------------
35# ROLE DEPENDENCY FACTORY
36# ----------------------------------------------------------------
37def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
38 """Get current active user"""
39 return current_user
42def require_roles(*roles: UserRole) -> Callable[..., User]:
43 """FastAPI dependency factory enforcing a role allow-list.
45 Returns a dependency that resolves the current user and raises 403
46 with a structured ``detail`` payload when the user's role is not in
47 ``roles``. The payload shape is::
49 {"code": "role_required", "required": ["admin", ...]}
51 so the frontend can render a precise "you need role X" message and
52 distinguish role-based 403s from resource-based 403s.
53 """
54 if not roles:
55 raise ValueError("require_roles() needs at least one role")
57 allowed = tuple(roles)
59 def _dep(user: User = Depends(get_current_active_user)) -> User:
60 if user.role not in allowed:
61 raise HTTPException(
62 status_code=status.HTTP_403_FORBIDDEN,
63 detail={
64 "code": "role_required",
65 "required": [r.value for r in allowed],
66 },
67 )
68 return user
70 return _dep
73# Canonical aliases for requiring a role on router dependencies.
74require_admin = require_roles(UserRole.ADMIN)
75require_staff = require_roles(UserRole.TEACHER, UserRole.ADMIN)
78# ----------------------------------------------------------------
79# DEPLOYMENT ACCESS (Owner / Team-Member / Teacher / Admin)
80# ----------------------------------------------------------------
81def has_deployment_access(deployment: Deployment, user: User, db: Session) -> bool:
82 """
83 Return True if `user` may read/manage `deployment`.
85 Allowed when any of:
86 - user is teacher or admin
87 - user is the deployment owner
88 - user is part of any team assigned to this deployment
89 (via UserToTeam joined to Team.deploymentId)
90 - user appears in UserToDeployment for this deployment
91 """
92 if user.role in STAFF_ROLES:
93 return True
94 if str(deployment.userId) == str(user.userId):
95 return True
97 team_match = (
98 db.query(UserToTeam.userToTeamId)
99 .join(Team, Team.teamId == UserToTeam.teamId)
100 .filter(
101 Team.deploymentId == deployment.deploymentId,
102 UserToTeam.userId == user.userId,
103 )
104 .first()
105 )
106 if team_match:
107 return True
109 direct_match = (
110 db.query(UserToDeployment.userToDeploymentId)
111 .filter(
112 UserToDeployment.deploymentId == deployment.deploymentId,
113 UserToDeployment.userId == user.userId,
114 )
115 .first()
116 )
117 return direct_match is not None
120def ensure_deployment_access(deployment: Deployment, user: User, db: Session) -> None:
121 """
122 Raise 403 unless `user` may access `deployment`.
124 Use this in every endpoint that takes a deployment_id from the URL/body
125 to prevent IDOR. Pass the loaded Deployment, not just the ID — callers
126 should already have fetched it (and should return 404 if missing before
127 calling this).
128 """
129 if not has_deployment_access(deployment, user, db):
130 raise HTTPException(
131 status_code=status.HTTP_403_FORBIDDEN,
132 detail="You don't have permission to access this deployment",
133 )
137def is_deployment_owner_view(deployment: Deployment, user: User) -> bool:
138 """True if ``user`` should see the *owner view* of ``deployment``.
140 The owner view shows everything (tasks, logs, terraform state, full
141 team rosters, destroy/delete); the member view shows only deployment
142 metadata, the user's own team, and resend-credentials for themself.
143 Teachers, admins, and the deployment creator get the owner view.
144 """
145 if user.role in STAFF_ROLES:
146 return True
147 return str(deployment.userId) == str(user.userId)
150def ensure_deployment_owner_view(deployment: Deployment, user: User) -> None:
151 """Raise 403 unless ``user`` has the owner view of ``deployment``.
153 Use on endpoints that expose deployment-internals (tasks, logs,
154 state, destroy/delete) — members have read-access to the
155 deployment itself but not to those.
156 """
157 if not is_deployment_owner_view(deployment, user):
158 raise HTTPException(
159 status_code=status.HTTP_403_FORBIDDEN,
160 detail="Only the deployment owner or staff can perform this action",
161 )