Skip to content

Commit 51957a0

Browse files
committed
refactor(rbac): query role and group tables to compute scopes
1 parent dd0896b commit 51957a0

File tree

4 files changed

+91
-51
lines changed

4 files changed

+91
-51
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: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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,66 @@ 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]:
89-
"""Compute the effective scopes for a role.
100+
async def compute_effective_scopes(
101+
session: AsyncDBSession, role: Role
102+
) -> frozenset[str]:
103+
"""Compute the effective scopes for a role from the database.
104+
105+
Queries UserRoleAssignment and GroupRoleAssignment tables to resolve
106+
scopes through Role → RoleScope → Scope.
90107
91108
Scope computation follows this hierarchy:
92109
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
96-
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
100-
101-
Note: Group-based scopes will be added in PR 4 (RBAC Service & APIs).
110+
2. Direct user role assignments (org-wide and workspace-specific)
111+
3. Group role assignments (org-wide and workspace-specific)
102112
"""
103113
if role.is_platform_superuser:
104114
return frozenset({"*"})
105115

106-
scope_set: set[str] = set()
107-
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())
111-
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())
116+
if role.user_id is None or role.organization_id is None:
117+
return frozenset()
118+
119+
# Direct user role assignments → Role → RoleScope → Scope
120+
user_scopes = (
121+
select(Scope.name)
122+
.join(RoleScope, RoleScope.scope_id == Scope.id)
123+
.join(RoleModel, RoleModel.id == RoleScope.role_id)
124+
.join(UserRoleAssignment, UserRoleAssignment.role_id == RoleModel.id)
125+
.where(
126+
UserRoleAssignment.user_id == role.user_id,
127+
UserRoleAssignment.organization_id == role.organization_id,
128+
(
129+
UserRoleAssignment.workspace_id.is_(None)
130+
| (UserRoleAssignment.workspace_id == role.workspace_id)
131+
)
132+
if role.workspace_id
133+
else UserRoleAssignment.workspace_id.is_(None),
134+
)
135+
)
119136

120-
# Note: Group-based scopes (from group_assignment table) will be added in PR 4
121-
# via RBACService.get_group_scopes()
137+
# Group role assignments → GroupMember → GroupRoleAssignment → Role → RoleScope → Scope
138+
group_scopes = (
139+
select(Scope.name)
140+
.join(RoleScope, RoleScope.scope_id == Scope.id)
141+
.join(RoleModel, RoleModel.id == RoleScope.role_id)
142+
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == RoleModel.id)
143+
.join(GroupMember, GroupMember.group_id == GroupRoleAssignment.group_id)
144+
.where(
145+
GroupMember.user_id == role.user_id,
146+
GroupRoleAssignment.organization_id == role.organization_id,
147+
(
148+
GroupRoleAssignment.workspace_id.is_(None)
149+
| (GroupRoleAssignment.workspace_id == role.workspace_id)
150+
)
151+
if role.workspace_id
152+
else GroupRoleAssignment.workspace_id.is_(None),
153+
)
154+
)
122155

123-
return frozenset(scope_set)
156+
# Single atomic query: union both assignment paths
157+
combined = user_scopes.union(group_scopes)
158+
result = await session.execute(combined)
159+
return frozenset(result.scalars().all())
124160

125161

126162
def get_role_from_user(
@@ -599,7 +635,8 @@ async def _authenticate_executor(
599635
return role
600636

601637

602-
def _validate_role(
638+
async def _validate_role(
639+
session: AsyncSession,
603640
role: Role,
604641
*,
605642
require_workspace: Literal["yes", "no", "optional"],
@@ -631,7 +668,7 @@ def _validate_role(
631668
)
632669

633670
# Compute effective scopes and create new role with scopes included
634-
scopes = compute_effective_scopes(role)
671+
scopes = await compute_effective_scopes(session, role)
635672
logger.debug(
636673
"Computed effective scopes",
637674
scope_count=len(scopes),
@@ -701,7 +738,8 @@ async def _role_dependency(
701738
detail="Unauthorized",
702739
)
703740
# Validate structural requirements and set context
704-
role = _validate_role(
741+
role = await _validate_role(
742+
session,
705743
role,
706744
require_workspace=require_workspace,
707745
min_access_level=min_access_level,
@@ -911,6 +949,7 @@ async def _require_superuser(
911949

912950
async def _authenticated_user_only(
913951
user: Annotated[User, Depends(current_active_user)],
952+
session: AsyncDBSession,
914953
) -> Role:
915954
"""Dependency for endpoints requiring only an authenticated user.
916955
@@ -931,7 +970,7 @@ async def _authenticated_user_only(
931970
is_platform_superuser=user.is_superuser,
932971
# organization_id intentionally None - user may not belong to any org
933972
)
934-
scopes = compute_effective_scopes(role)
973+
scopes = await compute_effective_scopes(session, role)
935974
role = role.model_copy(update={"scopes": scopes})
936975
ctx_role.set(role)
937976
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)