Skip to content

Commit a2069c4

Browse files
committed
refactor(rbac): query role and group tables to compute scopes
1 parent 4bc3e55 commit a2069c4

File tree

4 files changed

+103
-50
lines changed

4 files changed

+103
-50
lines changed

tests/unit/test_auth_middleware.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,11 @@ async def test_organization_id_populated_when_require_workspace_no(mocker):
557557
org_role_result = MagicMock()
558558
org_role_result.scalar_one_or_none.return_value = None
559559

560-
mock_session.execute.side_effect = [org_result, org_role_result]
560+
# Third call: compute_effective_scopes query returns empty scopes
561+
scopes_result = MagicMock()
562+
scopes_result.scalars.return_value.all.return_value = []
563+
564+
mock_session.execute.side_effect = [org_result, org_role_result, scopes_result]
561565

562566
# Mock is_unprivileged to return False for admin users
563567
mocker.patch("tracecat.auth.credentials.is_unprivileged", return_value=False)

tests/unit/test_rbac_scopes.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
scope_matches,
1515
validate_scope_string,
1616
)
17-
from tracecat.authz.enums import OrgRole, WorkspaceRole
1817
from tracecat.authz.scopes import (
1918
ADMIN_SCOPES,
2019
EDITOR_SCOPES,
@@ -202,9 +201,9 @@ def test_admin_includes_editor(self):
202201
assert EDITOR_SCOPES.issubset(ADMIN_SCOPES)
203202

204203
def test_system_role_mapping(self):
205-
assert PRESET_ROLE_SCOPES[WorkspaceRole.VIEWER] == VIEWER_SCOPES
206-
assert PRESET_ROLE_SCOPES[WorkspaceRole.EDITOR] == EDITOR_SCOPES
207-
assert PRESET_ROLE_SCOPES[WorkspaceRole.ADMIN] == ADMIN_SCOPES
204+
assert PRESET_ROLE_SCOPES["workspace-viewer"] == VIEWER_SCOPES
205+
assert PRESET_ROLE_SCOPES["workspace-editor"] == EDITOR_SCOPES
206+
assert PRESET_ROLE_SCOPES["workspace-admin"] == ADMIN_SCOPES
208207

209208

210209
class TestOrgRoleScopes:
@@ -226,9 +225,9 @@ def test_member_has_minimal_scopes(self):
226225
assert ORG_MEMBER_SCOPES == frozenset({"org:read", "org:member:read"})
227226

228227
def test_org_role_mapping(self):
229-
assert ORG_ROLE_SCOPES[OrgRole.OWNER] == ORG_OWNER_SCOPES
230-
assert ORG_ROLE_SCOPES[OrgRole.ADMIN] == ORG_ADMIN_SCOPES
231-
assert ORG_ROLE_SCOPES[OrgRole.MEMBER] == ORG_MEMBER_SCOPES
228+
assert ORG_ROLE_SCOPES["organization-owner"] == ORG_OWNER_SCOPES
229+
assert ORG_ROLE_SCOPES["organization-admin"] == ORG_ADMIN_SCOPES
230+
assert ORG_ROLE_SCOPES["organization-member"] == ORG_MEMBER_SCOPES
232231

233232

234233
class TestRequireScopeDecorator:

tracecat/auth/credentials.py

Lines changed: 84 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
status,
2020
)
2121
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
22-
from sqlalchemy import select
22+
from sqlalchemy import or_, select
2323
from sqlalchemy.ext.asyncio import AsyncSession
2424

2525
from tracecat import config
@@ -32,12 +32,24 @@
3232
optional_current_active_user,
3333
)
3434
from tracecat.authz.enums import OrgRole, WorkspaceRole
35-
from tracecat.authz.scopes import ORG_ROLE_SCOPES, PRESET_ROLE_SCOPES
3635
from tracecat.authz.service import MembershipService, MembershipWithOrg
3736
from tracecat.contexts import ctx_role
3837
from tracecat.db.dependencies import AsyncDBSession
3938
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+
)
4153
from tracecat.identifiers import InternalServiceID
4254
from tracecat.logger import logger
4355
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:
8597
ORG_OVERRIDE_COOKIE = "tracecat-org-id"
8698

8799

88-
def compute_effective_scopes(role: Role) -> frozenset[str]:
100+
async def compute_effective_scopes(role: Role) -> frozenset[str]:
89101
"""Compute the effective scopes for a role.
90102
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.
96106
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.
100109
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)
102114
"""
103115
if role.is_platform_superuser:
104116
return frozenset({"*"})
105117

106-
scope_set: set[str] = set()
118+
if role.user_id is None or role.organization_id is None:
119+
return frozenset()
107120

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+
)
111124

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())
119125

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+
)
122150

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())
124174

125175

126176
def get_role_from_user(
@@ -215,7 +265,9 @@ async def _authenticate_service(
215265
# Parse scopes from header if present (for inter-service calls)
216266
scopes: frozenset[str] = frozenset()
217267
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+
)
219271
service_id: InternalServiceID = service_role_id # type: ignore[assignment]
220272
return Role(
221273
type="service",
@@ -610,7 +662,7 @@ async def _authenticate_executor(
610662
return role
611663

612664

613-
def _validate_role(
665+
async def _validate_role(
614666
role: Role,
615667
*,
616668
require_workspace: Literal["yes", "no", "optional"],
@@ -642,7 +694,7 @@ def _validate_role(
642694
)
643695

644696
# Compute effective scopes and create new role with scopes included
645-
scopes = compute_effective_scopes(role)
697+
scopes = await compute_effective_scopes(role)
646698
logger.debug(
647699
"Computed effective scopes",
648700
scope_count=len(scopes),
@@ -712,7 +764,7 @@ async def _role_dependency(
712764
detail="Unauthorized",
713765
)
714766
# Validate structural requirements and set context
715-
role = _validate_role(
767+
role = await _validate_role(
716768
role,
717769
require_workspace=require_workspace,
718770
min_access_level=min_access_level,
@@ -942,7 +994,7 @@ async def _authenticated_user_only(
942994
is_platform_superuser=user.is_superuser,
943995
# organization_id intentionally None - user may not belong to any org
944996
)
945-
scopes = compute_effective_scopes(role)
997+
scopes = await compute_effective_scopes(role)
946998
role = role.model_copy(update={"scopes": scopes})
947999
ctx_role.set(role)
9481000
return role

tracecat/authz/scopes.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
from __future__ import annotations
1818

19-
from tracecat.authz.enums import OrgRole, WorkspaceRole
20-
2119
# =============================================================================
2220
# Viewer Role Scopes (read-only)
2321
# =============================================================================
@@ -86,10 +84,10 @@
8684
# Preset Role -> Scope Set Mapping
8785
# =============================================================================
8886

89-
PRESET_ROLE_SCOPES: dict[WorkspaceRole, frozenset[str]] = {
90-
WorkspaceRole.VIEWER: VIEWER_SCOPES,
91-
WorkspaceRole.EDITOR: EDITOR_SCOPES,
92-
WorkspaceRole.ADMIN: ADMIN_SCOPES,
87+
PRESET_ROLE_SCOPES: dict[str, frozenset[str]] = {
88+
"workspace-viewer": VIEWER_SCOPES,
89+
"workspace-editor": EDITOR_SCOPES,
90+
"workspace-admin": ADMIN_SCOPES,
9391
}
9492

9593
# =============================================================================
@@ -225,8 +223,8 @@
225223
# Organization Role -> Scope Set Mapping
226224
# =============================================================================
227225

228-
ORG_ROLE_SCOPES: dict[OrgRole, frozenset[str]] = {
229-
OrgRole.OWNER: ORG_OWNER_SCOPES,
230-
OrgRole.ADMIN: ORG_ADMIN_SCOPES,
231-
OrgRole.MEMBER: ORG_MEMBER_SCOPES,
226+
ORG_ROLE_SCOPES: dict[str, frozenset[str]] = {
227+
"organization-owner": ORG_OWNER_SCOPES,
228+
"organization-admin": ORG_ADMIN_SCOPES,
229+
"organization-member": ORG_MEMBER_SCOPES,
232230
}

0 commit comments

Comments
 (0)