|
19 | 19 | status, |
20 | 20 | ) |
21 | 21 | from fastapi.security import APIKeyHeader, OAuth2PasswordBearer |
22 | | -from sqlalchemy import select |
| 22 | +from sqlalchemy import or_, select |
23 | 23 | from sqlalchemy.ext.asyncio import AsyncSession |
24 | 24 |
|
25 | 25 | from tracecat import config |
|
32 | 32 | optional_current_active_user, |
33 | 33 | ) |
34 | 34 | from tracecat.authz.enums import OrgRole, WorkspaceRole |
35 | | -from tracecat.authz.scopes import ORG_ROLE_SCOPES, PRESET_ROLE_SCOPES |
36 | 35 | from tracecat.authz.service import MembershipService, MembershipWithOrg |
37 | 36 | from tracecat.contexts import ctx_role |
38 | 37 | from tracecat.db.dependencies import AsyncDBSession |
39 | 38 | from tracecat.db.engine import get_async_session_context_manager |
40 | | -from tracecat.db.models import Organization, OrganizationMembership, User, Workspace |
| 39 | +from tracecat.db.models import ( |
| 40 | + GroupMember, |
| 41 | + GroupRoleAssignment, |
| 42 | + Organization, |
| 43 | + OrganizationMembership, |
| 44 | + RoleScope, |
| 45 | + Scope, |
| 46 | + User, |
| 47 | + UserRoleAssignment, |
| 48 | + Workspace, |
| 49 | +) |
| 50 | +from tracecat.db.models import ( |
| 51 | + Role as RoleModel, |
| 52 | +) |
41 | 53 | from tracecat.identifiers import InternalServiceID |
42 | 54 | from tracecat.logger import logger |
43 | 55 | from tracecat.organization.management import get_default_organization_id |
@@ -85,42 +97,80 @@ async def _get_workspace_org_id(workspace_id: uuid.UUID) -> uuid.UUID | None: |
85 | 97 | ORG_OVERRIDE_COOKIE = "tracecat-org-id" |
86 | 98 |
|
87 | 99 |
|
88 | | -def compute_effective_scopes(role: Role) -> frozenset[str]: |
| 100 | +async def compute_effective_scopes(role: Role) -> frozenset[str]: |
89 | 101 | """Compute the effective scopes for a role. |
90 | 102 |
|
91 | | - Scope computation follows this hierarchy: |
92 | | - 1. Platform superusers get "*" (all scopes) |
93 | | - 2. Org OWNER/ADMIN get their org-level scopes (includes full workspace access) |
94 | | - 3. Org MEMBER gets base org scopes + workspace membership scopes (if in workspace) |
95 | | - 4. Service roles inherit scopes based on the user they're acting on behalf of |
| 103 | + Results are cached by (user_id, organization_id, workspace_id) with a |
| 104 | + 30-second TTL so that repeated requests from the same user don't |
| 105 | + re-run the multi-table JOIN query every time. |
96 | 106 |
|
97 | | - For workspace-scoped requests: |
98 | | - - Org OWNER/ADMIN: org-level scopes (they can access all workspaces) |
99 | | - - Workspace members: workspace role scopes from PRESET_ROLE_SCOPES |
| 107 | + Queries UserRoleAssignment and GroupRoleAssignment tables to resolve |
| 108 | + scopes through Role → RoleScope → Scope. |
100 | 109 |
|
101 | | - Note: Group-based scopes will be added in PR 4 (RBAC Service & APIs). |
| 110 | + Scope computation follows this hierarchy: |
| 111 | + 1. Platform superusers get "*" (all scopes) |
| 112 | + 2. Direct user role assignments (org-wide and workspace-specific) |
| 113 | + 3. Group role assignments (org-wide and workspace-specific) |
102 | 114 | """ |
103 | 115 | if role.is_platform_superuser: |
104 | 116 | return frozenset({"*"}) |
105 | 117 |
|
106 | | - scope_set: set[str] = set() |
| 118 | + if role.user_id is None or role.organization_id is None: |
| 119 | + return frozenset() |
107 | 120 |
|
108 | | - # Add org-level scopes based on org role |
109 | | - if role.org_role is not None: |
110 | | - scope_set |= ORG_ROLE_SCOPES.get(role.org_role, set()) |
| 121 | + return await _compute_effective_scopes_cached( |
| 122 | + role.user_id, role.organization_id, role.workspace_id |
| 123 | + ) |
111 | 124 |
|
112 | | - # For workspace-scoped requests, add workspace role scopes |
113 | | - # (only if not an org admin/owner, who already have full access via org scopes) |
114 | | - if role.workspace_id and role.workspace_role: |
115 | | - # Org admins/owners already have workspace scopes via their org role |
116 | | - # Regular members need their workspace role scopes |
117 | | - if not role.is_org_admin: |
118 | | - scope_set |= PRESET_ROLE_SCOPES.get(role.workspace_role, set()) |
119 | 125 |
|
120 | | - # Note: Group-based scopes (from group_assignment table) will be added in PR 4 |
121 | | - # via RBACService.get_group_scopes() |
| 126 | +@alru_cache(maxsize=10000, ttl=30) |
| 127 | +async def _compute_effective_scopes_cached( |
| 128 | + user_id: uuid.UUID, |
| 129 | + organization_id: uuid.UUID, |
| 130 | + workspace_id: uuid.UUID | None, |
| 131 | +) -> frozenset[str]: |
| 132 | + async with get_async_session_context_manager() as session: |
| 133 | + # Direct user role assignments → Role → RoleScope → Scope |
| 134 | + user_scopes = ( |
| 135 | + select(Scope.name) |
| 136 | + .join(RoleScope, RoleScope.scope_id == Scope.id) |
| 137 | + .join(RoleModel, RoleModel.id == RoleScope.role_id) |
| 138 | + .join(UserRoleAssignment, UserRoleAssignment.role_id == RoleModel.id) |
| 139 | + .where( |
| 140 | + UserRoleAssignment.user_id == user_id, |
| 141 | + UserRoleAssignment.organization_id == organization_id, |
| 142 | + or_( |
| 143 | + UserRoleAssignment.workspace_id.is_(None), |
| 144 | + UserRoleAssignment.workspace_id == workspace_id, |
| 145 | + ) |
| 146 | + if workspace_id |
| 147 | + else UserRoleAssignment.workspace_id.is_(None), |
| 148 | + ) |
| 149 | + ) |
122 | 150 |
|
123 | | - return frozenset(scope_set) |
| 151 | + # Group role assignments → GroupMember → GroupRoleAssignment → Role → RoleScope → Scope |
| 152 | + group_scopes = ( |
| 153 | + select(Scope.name) |
| 154 | + .join(RoleScope, RoleScope.scope_id == Scope.id) |
| 155 | + .join(RoleModel, RoleModel.id == RoleScope.role_id) |
| 156 | + .join(GroupRoleAssignment, GroupRoleAssignment.role_id == RoleModel.id) |
| 157 | + .join(GroupMember, GroupMember.group_id == GroupRoleAssignment.group_id) |
| 158 | + .where( |
| 159 | + GroupMember.user_id == user_id, |
| 160 | + GroupRoleAssignment.organization_id == organization_id, |
| 161 | + or_( |
| 162 | + GroupRoleAssignment.workspace_id.is_(None), |
| 163 | + GroupRoleAssignment.workspace_id == workspace_id, |
| 164 | + ) |
| 165 | + if workspace_id |
| 166 | + else GroupRoleAssignment.workspace_id.is_(None), |
| 167 | + ) |
| 168 | + ) |
| 169 | + |
| 170 | + # Single atomic query: union both assignment paths |
| 171 | + combined = user_scopes.union(group_scopes) |
| 172 | + result = await session.execute(combined) |
| 173 | + return frozenset(result.scalars().all()) |
124 | 174 |
|
125 | 175 |
|
126 | 176 | def get_role_from_user( |
@@ -215,7 +265,9 @@ async def _authenticate_service( |
215 | 265 | # Parse scopes from header if present (for inter-service calls) |
216 | 266 | scopes: frozenset[str] = frozenset() |
217 | 267 | if scopes_header := request.headers.get("x-tracecat-role-scopes"): |
218 | | - scopes = frozenset(s.strip() for s in scopes_header.split(",") if s.strip()) |
| 268 | + scopes = frozenset( |
| 269 | + stripped for s in scopes_header.split(",") if (stripped := s.strip()) |
| 270 | + ) |
219 | 271 | service_id: InternalServiceID = service_role_id # type: ignore[assignment] |
220 | 272 | return Role( |
221 | 273 | type="service", |
@@ -610,7 +662,7 @@ async def _authenticate_executor( |
610 | 662 | return role |
611 | 663 |
|
612 | 664 |
|
613 | | -def _validate_role( |
| 665 | +async def _validate_role( |
614 | 666 | role: Role, |
615 | 667 | *, |
616 | 668 | require_workspace: Literal["yes", "no", "optional"], |
@@ -642,7 +694,7 @@ def _validate_role( |
642 | 694 | ) |
643 | 695 |
|
644 | 696 | # Compute effective scopes and create new role with scopes included |
645 | | - scopes = compute_effective_scopes(role) |
| 697 | + scopes = await compute_effective_scopes(role) |
646 | 698 | logger.debug( |
647 | 699 | "Computed effective scopes", |
648 | 700 | scope_count=len(scopes), |
@@ -712,7 +764,7 @@ async def _role_dependency( |
712 | 764 | detail="Unauthorized", |
713 | 765 | ) |
714 | 766 | # Validate structural requirements and set context |
715 | | - role = _validate_role( |
| 767 | + role = await _validate_role( |
716 | 768 | role, |
717 | 769 | require_workspace=require_workspace, |
718 | 770 | min_access_level=min_access_level, |
@@ -942,7 +994,7 @@ async def _authenticated_user_only( |
942 | 994 | is_platform_superuser=user.is_superuser, |
943 | 995 | # organization_id intentionally None - user may not belong to any org |
944 | 996 | ) |
945 | | - scopes = compute_effective_scopes(role) |
| 997 | + scopes = await compute_effective_scopes(role) |
946 | 998 | role = role.model_copy(update={"scopes": scopes}) |
947 | 999 | ctx_role.set(role) |
948 | 1000 | return role |
|
0 commit comments