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

1"""Capability functions for role-based access control. 

2 

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. 

7 

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:: 

14 

15 {"code": "<machine_code>", "required": [...optional context...]} 

16 

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 

22 

23from uuid import UUID 

24 

25from fastapi import HTTPException, status 

26from sqlalchemy.orm import Session 

27 

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) 

34 

35 

36# ---------------------------------------------------------------- 

37# Internal helpers 

38# ---------------------------------------------------------------- 

39def _is_admin(user: User) -> bool: 

40 return user.role == UserRole.ADMIN 

41 

42 

43def _is_staff(user: User) -> bool: 

44 """User has a staff role (Teacher or Admin). 

45 

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 

50 

51 

52def _is_owner(user: User, owner_id) -> bool: 

53 return str(owner_id) == str(user.userId) 

54 

55 

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) 

61 

62 

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. 

68 

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) 

85 

86 

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") 

90 

91 

92def can_list_all_apps(user: User) -> bool: 

93 """Whether ``user`` may list every app (including private + unapproved). 

94 

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) 

99 

100 

101def ensure_list_all_apps(user: User) -> None: 

102 if not can_list_all_apps(user): 

103 raise _forbidden("role_required", [UserRole.ADMIN.value]) 

104 

105 

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) 

109 

110 

111def ensure_edit_app(user: User, app: App) -> None: 

112 if not can_edit_app(user, app): 

113 raise _forbidden("app_edit_forbidden") 

114 

115 

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) 

119 

120 

121def ensure_delete_app(user: User, app: App) -> None: 

122 if not can_delete_app(user, app): 

123 raise _forbidden("app_delete_forbidden") 

124 

125 

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) 

129 

130 

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") 

134 

135 

136def can_approve_app_version(user: User) -> bool: 

137 """Approve / reject / revoke a submitted version. Admin only.""" 

138 return _is_admin(user) 

139 

140 

141def ensure_approve_app_version(user: User) -> None: 

142 if not can_approve_app_version(user): 

143 raise _forbidden("role_required", [UserRole.ADMIN.value]) 

144 

145 

146# ================================================================ 

147# DEPLOYMENTS 

148# ================================================================ 

149def can_view_deployment_member(user: User, dep: Deployment, db: Session) -> bool: 

150 """Member-view access to a deployment. 

151 

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) 

156 

157 

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") 

161 

162 

163def can_view_deployment_owner(user: User, dep: Deployment, db: Session) -> bool: 

164 """Owner-view access — tasks, logs, terraform state, destroy. 

165 

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 

175 

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) 

184 

185 

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") 

189 

190 

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) 

195 

196 

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") 

200 

201 

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``. 

209 

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) 

217 

218 

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") 

227 

228 

229# ================================================================ 

230# COURSES 

231# ================================================================ 

232def is_course_teacher(user: User, course: Course, db: Session) -> bool: 

233 """Whether ``user`` is a designated teacher of ``course``. 

234 

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) 

241 

242 

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 

259 

260 

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. 

263 

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} 

277 

278 

279def can_view_course_detail(user: User) -> bool: 

280 """Whether ``user`` may read course details + member rosters. 

281 

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) 

286 

287 

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]) 

291 

292 

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) 

298 

299 

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") 

303 

304 

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``. 

310 

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) 

317 

318 

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") 

322 

323 

324def can_change_user_role(actor: User) -> bool: 

325 """Whether ``actor`` may change someone else's role. Admin only.""" 

326 return _is_admin(actor) 

327 

328 

329def ensure_change_user_role(actor: User) -> None: 

330 if not can_change_user_role(actor): 

331 raise _forbidden("role_required", [UserRole.ADMIN.value]) 

332 

333 

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]