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

1""" 

2Permission and authorization utilities for role-based access control. 

3 

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 

9 

10from fastapi import Depends, HTTPException, status 

11from sqlalchemy.orm import Session 

12 

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 

22 

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

32 

33 

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 

40 

41 

42def require_roles(*roles: UserRole) -> Callable[..., User]: 

43 """FastAPI dependency factory enforcing a role allow-list. 

44 

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

48 

49 {"code": "role_required", "required": ["admin", ...]} 

50 

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

56 

57 allowed = tuple(roles) 

58 

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 

69 

70 return _dep 

71 

72 

73# Canonical aliases for requiring a role on router dependencies. 

74require_admin = require_roles(UserRole.ADMIN) 

75require_staff = require_roles(UserRole.TEACHER, UserRole.ADMIN) 

76 

77 

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

84 

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 

96 

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 

108 

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 

118 

119 

120def ensure_deployment_access(deployment: Deployment, user: User, db: Session) -> None: 

121 """ 

122 Raise 403 unless `user` may access `deployment`. 

123 

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 ) 

134 

135 

136 

137def is_deployment_owner_view(deployment: Deployment, user: User) -> bool: 

138 """True if ``user`` should see the *owner view* of ``deployment``. 

139 

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) 

148 

149 

150def ensure_deployment_owner_view(deployment: Deployment, user: User) -> None: 

151 """Raise 403 unless ``user`` has the owner view of ``deployment``. 

152 

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 )