Spaces:
Sleeping
Sleeping
feat: implement org admin permissions, org-scoped filtering, and audit log updates
Browse files- src/app/api/deps.py +27 -0
- src/app/api/v1/audit_logs.py +77 -7
- src/app/api/v1/clients.py +9 -0
- src/app/api/v1/contractors.py +9 -0
- src/app/api/v1/users.py +16 -8
- src/app/core/permissions.py +15 -1
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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|