Coverage for app/utils/capabilities.py: 99.20%
125 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"""Capability functions for role-based access control.
3This module is the single source of truth for "may this user do X?"
4questions. Every router endpoint that goes beyond a plain role check
5should reach for a ``can_*`` (boolean) or ``ensure_*`` (raises) helper
6here instead of poking at ``user.role`` directly.
8Conventions:
9 - ``can_<verb>_<resource>(user, ..., *, db=None) -> bool`` answers
10 the permission question and never raises.
11 - ``ensure_<verb>_<resource>(...)`` calls the ``can_*`` form and
12 raises :class:`fastapi.HTTPException` with status 403 and a
13 structured ``detail`` payload::
15 {"code": "<machine_code>", "required": [...optional context...]}
17 The ``code`` lets the frontend render specific error messages.
18 For role-shaped rejections, ``code="role_required"`` matches the
19 payload produced by :func:`app.utils.permissions.require_roles`.
20"""
21from __future__ import annotations
23from uuid import UUID
25from fastapi import HTTPException, status
26from sqlalchemy.orm import Session
28from app.crud import app_version_approvals as crud_approvals
29from app.models import App, Course, CourseTeacher, Deployment, User, UserRole
30from app.utils.permissions import (
31 STAFF_ROLES,
32 has_deployment_access,
33)
36# ----------------------------------------------------------------
37# Internal helpers
38# ----------------------------------------------------------------
39def _is_admin(user: User) -> bool:
40 return user.role == UserRole.ADMIN
43def _is_staff(user: User) -> bool:
44 """User has a staff role (Teacher or Admin).
46 This is the role-shaped check; for per-resource course-teacher
47 rights use :func:`is_course_teacher`.
48 """
49 return user.role in STAFF_ROLES
52def _is_owner(user: User, owner_id) -> bool:
53 return str(owner_id) == str(user.userId)
56def _forbidden(code: str, required: list[str] | None = None) -> HTTPException:
57 detail: dict = {"code": code}
58 if required is not None:
59 detail["required"] = required
60 return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
63# ================================================================
64# APPS
65# ================================================================
66def can_view_app(user: User, app: App, *, db: Session | None = None) -> bool:
67 """Whether ``user`` may see ``app`` at all.
69 Allowed when:
70 - user owns the app, OR
71 - user is admin, OR
72 - app is public AND has at least one approved version.
73 """
74 if _is_owner(user, app.userId):
75 return True
76 if _is_admin(user):
77 return True
78 if app.is_private:
79 return False
80 if db is None:
81 # Without a DB handle we can't verify the approved-version
82 # requirement; the safe default is "no".
83 return False
84 return crud_approvals.has_any_approved_version(db, app.appId)
87def ensure_view_app(user: User, app: App, *, db: Session | None = None) -> None:
88 if not can_view_app(user, app, db=db):
89 raise _forbidden("app_view_forbidden")
92def can_list_all_apps(user: User) -> bool:
93 """Whether ``user`` may list every app (including private + unapproved).
95 Admin only. Other staff members see "my apps + public approved
96 apps" — that is a query-shape concern, not a flat boolean.
97 """
98 return _is_admin(user)
101def ensure_list_all_apps(user: User) -> None:
102 if not can_list_all_apps(user):
103 raise _forbidden("role_required", [UserRole.ADMIN.value])
106def can_edit_app(user: User, app: App) -> bool:
107 """Whether ``user`` may edit ``app``'s metadata. Owner or admin only."""
108 return _is_admin(user) or _is_owner(user, app.userId)
111def ensure_edit_app(user: User, app: App) -> None:
112 if not can_edit_app(user, app):
113 raise _forbidden("app_edit_forbidden")
116def can_delete_app(user: User, app: App) -> bool:
117 """Whether ``user`` may (soft-)delete ``app``. Owner or admin only."""
118 return _is_admin(user) or _is_owner(user, app.userId)
121def ensure_delete_app(user: User, app: App) -> None:
122 if not can_delete_app(user, app):
123 raise _forbidden("app_delete_forbidden")
126def can_submit_app_version(user: User, app: App) -> bool:
127 """Submit a version for approval review. Owner or admin only."""
128 return _is_admin(user) or _is_owner(user, app.userId)
131def ensure_submit_app_version(user: User, app: App) -> None:
132 if not can_submit_app_version(user, app):
133 raise _forbidden("app_submit_forbidden")
136def can_approve_app_version(user: User) -> bool:
137 """Approve / reject / revoke a submitted version. Admin only."""
138 return _is_admin(user)
141def ensure_approve_app_version(user: User) -> None:
142 if not can_approve_app_version(user):
143 raise _forbidden("role_required", [UserRole.ADMIN.value])
146# ================================================================
147# DEPLOYMENTS
148# ================================================================
149def can_view_deployment_member(user: User, dep: Deployment, db: Session) -> bool:
150 """Member-view access to a deployment.
152 Mirrors :func:`app.utils.permissions.has_deployment_access` —
153 owner, staff, team-member, or direct UserToDeployment mapping.
154 """
155 return has_deployment_access(dep, user, db)
158def ensure_view_deployment_member(user: User, dep: Deployment, db: Session) -> None:
159 if not can_view_deployment_member(user, dep, db):
160 raise _forbidden("deployment_view_forbidden")
163def can_view_deployment_owner(user: User, dep: Deployment, db: Session) -> bool:
164 """Owner-view access — tasks, logs, terraform state, destroy.
166 Read access is granted to the deployment owner, admins, and
167 course-teachers of the deployment owner's course (inspect only).
168 Operate rights are separate — see :func:`can_operate_deployment`.
169 The course-teacher check is skipped when the owner has no course.
170 """
171 if user.role == UserRole.ADMIN:
172 return True
173 if str(dep.userId) == str(user.userId):
174 return True
176 # Course-teacher inspect right applies only to teachers; other
177 # roles are rejected by the role gate in ``is_course_teacher_id``.
178 if user.role != UserRole.TEACHER:
179 return False
180 owner_course_id = getattr(getattr(dep, "user", None), "courseId", None)
181 if owner_course_id is None:
182 return False
183 return is_course_teacher_id(user, owner_course_id, db)
186def ensure_view_deployment_owner(user: User, dep: Deployment, db: Session) -> None:
187 if not can_view_deployment_owner(user, dep, db):
188 raise _forbidden("deployment_owner_view_forbidden")
191def can_operate_deployment(user: User, dep: Deployment, db: Session) -> bool:
192 """Pause / Resume / Destroy / Redeploy on a deployment. Owner or admin only."""
193 del db
194 return _is_admin(user) or _is_owner(user, dep.userId)
197def ensure_operate_deployment(user: User, dep: Deployment, db: Session) -> None:
198 if not can_operate_deployment(user, dep, db):
199 raise _forbidden("deployment_operate_forbidden")
202def can_resend_access(
203 user: User,
204 dep: Deployment,
205 target_user_id: UUID | str,
206 db: Session,
207) -> bool:
208 """Resend access credentials for ``target_user_id`` on ``dep``.
210 A user may resend credentials to themself on any deployment they
211 can member-view; staff may resend credentials to anyone on a
212 deployment they can owner-view.
213 """
214 if str(target_user_id) == str(user.userId):
215 return can_view_deployment_member(user, dep, db)
216 return can_view_deployment_owner(user, dep, db)
219def ensure_resend_access(
220 user: User,
221 dep: Deployment,
222 target_user_id: UUID | str,
223 db: Session,
224) -> None:
225 if not can_resend_access(user, dep, target_user_id, db):
226 raise _forbidden("deployment_resend_forbidden")
229# ================================================================
230# COURSES
231# ================================================================
232def is_course_teacher(user: User, course: Course, db: Session) -> bool:
233 """Whether ``user`` is a designated teacher of ``course``.
235 A user is a course-teacher for ``course`` exactly when their role
236 is ``TEACHER`` and a ``(course_id, user_id)`` row exists in
237 ``course_teachers``. Students never qualify — the role gate stays
238 primary. Admins are handled by the admin bypass at each call site.
239 """
240 return is_course_teacher_id(user, course.courseId, db)
243def is_course_teacher_id(user: User, course_id: UUID, db: Session) -> bool:
244 """Variant of :func:`is_course_teacher` when only the course id
245 is known. Used by helpers that need to filter rows by course
246 without materialising the ``Course`` object.
247 """
248 if user.role != UserRole.TEACHER:
249 return False
250 row = (
251 db.query(CourseTeacher)
252 .filter(
253 CourseTeacher.courseId == course_id,
254 CourseTeacher.userId == user.userId,
255 )
256 .first()
257 )
258 return row is not None
261def get_my_course_teacher_ids(user: User, db: Session) -> set[UUID]:
262 """Load the set of course IDs ``user`` is a designated teacher of.
264 Returns the empty set for non-teacher roles. Intended to be called
265 once per request and threaded into list-shaping helpers to avoid an
266 ``is_course_teacher`` query per row (N+1); currently the data source
267 for the ``?scope=course`` filter on the deployments list.
268 """
269 if user.role != UserRole.TEACHER:
270 return set()
271 rows = (
272 db.query(CourseTeacher.courseId)
273 .filter(CourseTeacher.userId == user.userId)
274 .all()
275 )
276 return {row[0] for row in rows}
279def can_view_course_detail(user: User) -> bool:
280 """Whether ``user`` may read course details + member rosters.
282 Today: staff only. Students see the courses they're enrolled in
283 via a different endpoint shape, not this one.
284 """
285 return _is_staff(user)
288def ensure_view_course_detail(user: User) -> None:
289 if not can_view_course_detail(user):
290 raise _forbidden("role_required", [r.value for r in STAFF_ROLES])
293def can_edit_course(user: User, course: Course, db: Session) -> bool:
294 """Edit / delete ``course``. Course-teacher of this course or admin."""
295 if _is_admin(user):
296 return True
297 return is_course_teacher(user, course, db)
300def ensure_edit_course(user: User, course: Course, db: Session) -> None:
301 if not can_edit_course(user, course, db):
302 raise _forbidden("course_edit_forbidden")
305# ================================================================
306# USERS
307# ================================================================
308def can_view_user(actor: User, target_id: UUID | str) -> bool:
309 """Whether ``actor`` may read the profile at ``target_id``.
311 Mirrors today: ``/me`` is always allowed (handled separately by
312 the router), seeing someone else requires a staff role.
313 """
314 if str(actor.userId) == str(target_id):
315 return True
316 return _is_staff(actor)
319def ensure_view_user(actor: User, target_id: UUID | str) -> None:
320 if not can_view_user(actor, target_id):
321 raise _forbidden("user_view_forbidden")
324def can_change_user_role(actor: User) -> bool:
325 """Whether ``actor`` may change someone else's role. Admin only."""
326 return _is_admin(actor)
329def ensure_change_user_role(actor: User) -> None:
330 if not can_change_user_role(actor):
331 raise _forbidden("role_required", [UserRole.ADMIN.value])
334__all__ = [
335 # Apps
336 "can_view_app",
337 "ensure_view_app",
338 "can_list_all_apps",
339 "ensure_list_all_apps",
340 "can_edit_app",
341 "ensure_edit_app",
342 "can_delete_app",
343 "ensure_delete_app",
344 "can_submit_app_version",
345 "ensure_submit_app_version",
346 "can_approve_app_version",
347 "ensure_approve_app_version",
348 # Deployments
349 "can_view_deployment_member",
350 "ensure_view_deployment_member",
351 "can_view_deployment_owner",
352 "ensure_view_deployment_owner",
353 "can_operate_deployment",
354 "ensure_operate_deployment",
355 "can_resend_access",
356 "ensure_resend_access",
357 # Courses
358 "is_course_teacher",
359 "is_course_teacher_id",
360 "get_my_course_teacher_ids",
361 "can_view_course_detail",
362 "ensure_view_course_detail",
363 "can_edit_course",
364 "ensure_edit_course",
365 # Users
366 "can_view_user",
367 "ensure_view_user",
368 "can_change_user_role",
369 "ensure_change_user_role",
370]