kamau1 commited on
Commit
b43d99a
·
1 Parent(s): fa8b99b

feat: implement org admin permissions, org-scoped filtering, and audit log updates

Browse files
src/app/api/deps.py CHANGED
@@ -98,3 +98,30 @@ async def get_current_active_user(
98
  detail="Inactive user"
99
  )
100
  return current_user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  detail="Inactive user"
99
  )
100
  return current_user
101
+
102
+
103
+ def get_org_scope(user: User) -> dict:
104
+ """
105
+ Get organization scope for org admin users.
106
+
107
+ Returns dict with org filtering info:
108
+ - is_org_scoped: bool - Whether user is org admin (needs scoping)
109
+ - client_id: UUID or None - Client org to filter by
110
+ - contractor_id: UUID or None - Contractor org to filter by
111
+
112
+ Platform admins and non-org-admins return is_org_scoped=False
113
+ """
114
+ if user.role in ['client_admin', 'contractor_admin']:
115
+ return {
116
+ 'is_org_scoped': True,
117
+ 'client_id': user.client_id,
118
+ 'contractor_id': user.contractor_id,
119
+ 'org_type': 'client' if user.client_id else 'contractor'
120
+ }
121
+
122
+ return {
123
+ 'is_org_scoped': False,
124
+ 'client_id': None,
125
+ 'contractor_id': None,
126
+ 'org_type': None
127
+ }
src/app/api/v1/audit_logs.py CHANGED
@@ -17,7 +17,7 @@ router = APIRouter()
17
 
18
 
19
  @router.get("", response_model=AuditLogListResponse)
20
- @require_role(["platform_admin"])
21
  def get_audit_logs(
22
  skip: int = Query(0, ge=0),
23
  limit: int = Query(100, ge=1, le=1000),
@@ -31,7 +31,11 @@ def get_audit_logs(
31
  current_user: User = Depends(get_current_user)
32
  ):
33
  """
34
- Get audit logs with filtering and pagination (Platform Admin only)
 
 
 
 
35
 
36
  Query parameters:
37
  - skip: Number of records to skip (pagination)
@@ -46,6 +50,22 @@ def get_audit_logs(
46
  # Build query
47
  query = db.query(AuditLog)
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  # Apply filters
50
  if user_id:
51
  query = query.filter(AuditLog.user_id == user_id)
@@ -86,27 +106,50 @@ def get_audit_logs(
86
 
87
 
88
  @router.get("/{audit_log_id}", response_model=AuditLogResponse)
89
- @require_role(["platform_admin"])
90
  def get_audit_log(
91
  audit_log_id: str,
92
  db: Session = Depends(get_db),
93
  current_user: User = Depends(get_current_user)
94
  ):
95
  """
96
- Get a specific audit log entry by ID (Platform Admin only)
 
 
 
 
97
  """
98
  from app.core.exceptions import NotFoundException
 
99
 
100
  audit_log = db.query(AuditLog).filter(AuditLog.id == audit_log_id).first()
101
 
102
  if not audit_log:
103
- raise NotFoundException(f"Audit log with ID {audit_log_id} not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  return audit_log
106
 
107
 
108
  @router.get("/user/{user_id}", response_model=AuditLogListResponse)
109
- @require_role(["platform_admin"])
110
  def get_user_audit_logs(
111
  user_id: str,
112
  skip: int = Query(0, ge=0),
@@ -115,8 +158,35 @@ def get_user_audit_logs(
115
  current_user: User = Depends(get_current_user)
116
  ):
117
  """
118
- Get all audit logs for a specific user (Platform Admin only)
 
 
 
 
119
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  query = db.query(AuditLog).filter(AuditLog.user_id == user_id)
121
 
122
  total = query.count()
 
17
 
18
 
19
  @router.get("", response_model=AuditLogListResponse)
20
+ @require_role(["platform_admin", "client_admin", "contractor_admin"])
21
  def get_audit_logs(
22
  skip: int = Query(0, ge=0),
23
  limit: int = Query(100, ge=1, le=1000),
 
31
  current_user: User = Depends(get_current_user)
32
  ):
33
  """
34
+ Get audit logs with filtering and pagination
35
+
36
+ **Authorization:**
37
+ - Platform admins see all audit logs
38
+ - Org admins see only logs from users in their organization
39
 
40
  Query parameters:
41
  - skip: Number of records to skip (pagination)
 
50
  # Build query
51
  query = db.query(AuditLog)
52
 
53
+ # Org scoping for org admins
54
+ # Filter to show only logs from users in their organization
55
+ if current_user.role in ['client_admin', 'contractor_admin']:
56
+ # Get all user IDs in the same org
57
+ org_users_query = db.query(User.id).filter(User.deleted_at == None)
58
+
59
+ if current_user.client_id:
60
+ org_users_query = org_users_query.filter(User.client_id == current_user.client_id)
61
+ elif current_user.contractor_id:
62
+ org_users_query = org_users_query.filter(User.contractor_id == current_user.contractor_id)
63
+
64
+ org_user_ids = [str(u.id) for u in org_users_query.all()]
65
+
66
+ # Filter audit logs to only show actions by users in the org
67
+ query = query.filter(AuditLog.user_id.in_(org_user_ids))
68
+
69
  # Apply filters
70
  if user_id:
71
  query = query.filter(AuditLog.user_id == user_id)
 
106
 
107
 
108
  @router.get("/{audit_log_id}", response_model=AuditLogResponse)
109
+ @require_role(["platform_admin", "client_admin", "contractor_admin"])
110
  def get_audit_log(
111
  audit_log_id: str,
112
  db: Session = Depends(get_db),
113
  current_user: User = Depends(get_current_user)
114
  ):
115
  """
116
+ Get a specific audit log entry by ID
117
+
118
+ **Authorization:**
119
+ - Platform admins can view any log
120
+ - Org admins can only view logs from users in their organization
121
  """
122
  from app.core.exceptions import NotFoundException
123
+ from fastapi import HTTPException, status
124
 
125
  audit_log = db.query(AuditLog).filter(AuditLog.id == audit_log_id).first()
126
 
127
  if not audit_log:
128
+ raise NotFoundException("Audit log not found")
129
+
130
+ # Org scoping check
131
+ if current_user.role in ['client_admin', 'contractor_admin']:
132
+ # Get the user who performed this action
133
+ log_user = db.query(User).filter(User.id == audit_log.user_id).first()
134
+
135
+ if log_user:
136
+ # Check if the log user is in the same org
137
+ if current_user.client_id and log_user.client_id != current_user.client_id:
138
+ raise HTTPException(
139
+ status_code=status.HTTP_403_FORBIDDEN,
140
+ detail="You can only view audit logs from your organization"
141
+ )
142
+ if current_user.contractor_id and log_user.contractor_id != current_user.contractor_id:
143
+ raise HTTPException(
144
+ status_code=status.HTTP_403_FORBIDDEN,
145
+ detail="You can only view audit logs from your organization"
146
+ )
147
 
148
  return audit_log
149
 
150
 
151
  @router.get("/user/{user_id}", response_model=AuditLogListResponse)
152
+ @require_role(["platform_admin", "client_admin", "contractor_admin"])
153
  def get_user_audit_logs(
154
  user_id: str,
155
  skip: int = Query(0, ge=0),
 
158
  current_user: User = Depends(get_current_user)
159
  ):
160
  """
161
+ Get all audit logs for a specific user
162
+
163
+ **Authorization:**
164
+ - Platform admins can view logs for any user
165
+ - Org admins can only view logs for users in their organization
166
  """
167
+ from fastapi import HTTPException, status
168
+
169
+ # Org scoping check - verify the target user is in the same org
170
+ if current_user.role in ['client_admin', 'contractor_admin']:
171
+ target_user = db.query(User).filter(User.id == user_id).first()
172
+
173
+ if not target_user:
174
+ raise HTTPException(
175
+ status_code=status.HTTP_404_NOT_FOUND,
176
+ detail="User not found"
177
+ )
178
+
179
+ if current_user.client_id and target_user.client_id != current_user.client_id:
180
+ raise HTTPException(
181
+ status_code=status.HTTP_403_FORBIDDEN,
182
+ detail="You can only view audit logs for users in your organization"
183
+ )
184
+ if current_user.contractor_id and target_user.contractor_id != current_user.contractor_id:
185
+ raise HTTPException(
186
+ status_code=status.HTTP_403_FORBIDDEN,
187
+ detail="You can only view audit logs for users in your organization"
188
+ )
189
+
190
  query = db.query(AuditLog).filter(AuditLog.user_id == user_id)
191
 
192
  total = query.count()
src/app/api/v1/clients.py CHANGED
@@ -104,12 +104,21 @@ async def list_clients(
104
  """
105
  List all clients with pagination
106
 
 
 
 
 
 
107
  - **skip**: Number of records to skip (default: 0)
108
  - **limit**: Maximum number of records to return (default: 100)
109
  - **is_active**: Filter by active status (optional)
110
  """
111
  query = db.query(Client).filter(Client.deleted_at == None)
112
 
 
 
 
 
113
  if is_active is not None:
114
  query = query.filter(Client.is_active == is_active)
115
 
 
104
  """
105
  List all clients with pagination
106
 
107
+ **Authorization:**
108
+ - Platform admins see all clients
109
+ - Client admins see only their own client organization
110
+ - Contractor admins see all clients (their business partners)
111
+
112
  - **skip**: Number of records to skip (default: 0)
113
  - **limit**: Maximum number of records to return (default: 100)
114
  - **is_active**: Filter by active status (optional)
115
  """
116
  query = db.query(Client).filter(Client.deleted_at == None)
117
 
118
+ # Org scoping for client_admin
119
+ if current_user.role == 'client_admin' and current_user.client_id:
120
+ query = query.filter(Client.id == current_user.client_id)
121
+
122
  if is_active is not None:
123
  query = query.filter(Client.is_active == is_active)
124
 
src/app/api/v1/contractors.py CHANGED
@@ -105,12 +105,21 @@ async def list_contractors(
105
  """
106
  List all contractors with pagination
107
 
 
 
 
 
 
108
  - **skip**: Number of records to skip (default: 0)
109
  - **limit**: Maximum number of records to return (default: 100)
110
  - **is_active**: Filter by active status (optional)
111
  """
112
  query = db.query(Contractor).filter(Contractor.deleted_at == None)
113
 
 
 
 
 
114
  if is_active is not None:
115
  query = query.filter(Contractor.is_active == is_active)
116
 
 
105
  """
106
  List all contractors with pagination
107
 
108
+ **Authorization:**
109
+ - Platform admins see all contractors
110
+ - Contractor admins see only their own contractor organization
111
+ - Client admins see all contractors (their service providers)
112
+
113
  - **skip**: Number of records to skip (default: 0)
114
  - **limit**: Maximum number of records to return (default: 100)
115
  - **is_active**: Filter by active status (optional)
116
  """
117
  query = db.query(Contractor).filter(Contractor.deleted_at == None)
118
 
119
+ # Org scoping for contractor_admin
120
+ if current_user.role == 'contractor_admin' and current_user.contractor_id:
121
+ query = query.filter(Contractor.id == current_user.contractor_id)
122
+
123
  if is_active is not None:
124
  query = query.filter(Contractor.is_active == is_active)
125
 
src/app/api/v1/users.py CHANGED
@@ -670,6 +670,7 @@ class AdminPasswordResetRequest(BaseModel):
670
  new_password: str
671
 
672
  @router.post("/{user_id}/reset-password", status_code=status.HTTP_200_OK)
 
673
  async def admin_reset_user_password(
674
  user_id: UUID,
675
  data: AdminPasswordResetRequest,
@@ -681,20 +682,14 @@ async def admin_reset_user_password(
681
  Admin resets user's password (Supabase Admin API)
682
 
683
  **Authorization:**
684
- - Platform admin only
 
685
 
686
  **Use Cases:**
687
  - User forgot password and verified via OTP
688
  - Password recovery after identity verification
689
  - Emergency access restoration
690
  """
691
- # Only platform admins can reset passwords
692
- if current_user.role != 'platform_admin':
693
- raise HTTPException(
694
- status_code=status.HTTP_403_FORBIDDEN,
695
- detail="Only platform administrators can reset user passwords"
696
- )
697
-
698
  # Get target user
699
  user = db.query(User).filter(
700
  User.id == user_id,
@@ -707,6 +702,19 @@ async def admin_reset_user_password(
707
  detail="User not found"
708
  )
709
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  try:
711
  # Use Supabase Admin API to update user password
712
  response = supabase_admin.auth.admin.update_user_by_id(
 
670
  new_password: str
671
 
672
  @router.post("/{user_id}/reset-password", status_code=status.HTTP_200_OK)
673
+ @require_permission("reset_user_password")
674
  async def admin_reset_user_password(
675
  user_id: UUID,
676
  data: AdminPasswordResetRequest,
 
682
  Admin resets user's password (Supabase Admin API)
683
 
684
  **Authorization:**
685
+ - Platform admin (all users)
686
+ - Org admins (users in their organization only)
687
 
688
  **Use Cases:**
689
  - User forgot password and verified via OTP
690
  - Password recovery after identity verification
691
  - Emergency access restoration
692
  """
 
 
 
 
 
 
 
693
  # Get target user
694
  user = db.query(User).filter(
695
  User.id == user_id,
 
702
  detail="User not found"
703
  )
704
 
705
+ # Org admins can only reset passwords for users in their org
706
+ if current_user.role in ['client_admin', 'contractor_admin']:
707
+ if current_user.client_id and user.client_id != current_user.client_id:
708
+ raise HTTPException(
709
+ status_code=status.HTTP_403_FORBIDDEN,
710
+ detail="You can only reset passwords for users in your organization"
711
+ )
712
+ if current_user.contractor_id and user.contractor_id != current_user.contractor_id:
713
+ raise HTTPException(
714
+ status_code=status.HTTP_403_FORBIDDEN,
715
+ detail="You can only reset passwords for users in your organization"
716
+ )
717
+
718
  try:
719
  # Use Supabase Admin API to update user password
720
  response = supabase_admin.auth.admin.update_user_by_id(
src/app/core/permissions.py CHANGED
@@ -59,15 +59,25 @@ class AppRole(str, Enum):
59
  # Platform admin has "*" (all permissions)
60
  # All other roles have explicit permission lists
61
  ROLE_PERMISSIONS: Dict[AppRole, List[str]] = {
62
- AppRole.PLATFORM_ADMIN: ["*"], # All permissions
 
 
 
 
 
 
63
  AppRole.CLIENT_ADMIN: [
64
  # User Management
65
  "view_users",
66
  "invite_users",
67
  "manage_org_users", # Can manage users in their organization
 
 
68
  # Organization Management
69
  "create_clients",
70
  "create_contractors", # Can onboard contractors they work with
 
 
71
  # Project Management
72
  "manage_projects",
73
  "view_reports",
@@ -83,9 +93,13 @@ ROLE_PERMISSIONS: Dict[AppRole, List[str]] = {
83
  "invite_users",
84
  "manage_org_users", # Can manage users in their organization
85
  "manage_agents",
 
 
86
  # Organization Management
87
  "create_clients", # Can onboard clients they work with
88
  "create_contractors",
 
 
89
  # Project Management
90
  "accept_projects",
91
  "view_payroll",
 
59
  # Platform admin has "*" (all permissions)
60
  # All other roles have explicit permission lists
61
  ROLE_PERMISSIONS: Dict[AppRole, List[str]] = {
62
+ AppRole.PLATFORM_ADMIN: [
63
+ "*", # All permissions
64
+ "reset_user_password", # Explicitly included for clarity
65
+ "view_audit_logs",
66
+ "view_organizations",
67
+ "view_billing"
68
+ ],
69
  AppRole.CLIENT_ADMIN: [
70
  # User Management
71
  "view_users",
72
  "invite_users",
73
  "manage_org_users", # Can manage users in their organization
74
+ "reset_user_password", # Can reset passwords for users in their org
75
+ "view_audit_logs", # Can view audit logs for their org
76
  # Organization Management
77
  "create_clients",
78
  "create_contractors", # Can onboard contractors they work with
79
+ "view_organizations", # Can view their own organization
80
+ "view_billing", # Can view their org's billing/subscription
81
  # Project Management
82
  "manage_projects",
83
  "view_reports",
 
93
  "invite_users",
94
  "manage_org_users", # Can manage users in their organization
95
  "manage_agents",
96
+ "reset_user_password", # Can reset passwords for users in their org
97
+ "view_audit_logs", # Can view audit logs for their org
98
  # Organization Management
99
  "create_clients", # Can onboard clients they work with
100
  "create_contractors",
101
+ "view_organizations", # Can view their own organization
102
+ "view_billing", # Can view their org's billing/subscription
103
  # Project Management
104
  "accept_projects",
105
  "view_payroll",