Skip to content

Commit dd0896b

Browse files
committed
refactor(rbac): pass scopes via role ctx
1 parent c4f668d commit dd0896b

File tree

7 files changed

+70
-30
lines changed

7 files changed

+70
-30
lines changed

frontend/src/client/schemas.gen.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13197,6 +13197,15 @@ export const $Role = {
1319713197
title: "Is Platform Superuser",
1319813198
default: false,
1319913199
},
13200+
scopes: {
13201+
items: {
13202+
type: "string",
13203+
},
13204+
type: "array",
13205+
uniqueItems: true,
13206+
title: "Scopes",
13207+
default: [],
13208+
},
1320013209
},
1320113210
type: "object",
1320213211
required: ["type", "service_id"],

frontend/src/client/types.gen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4218,6 +4218,7 @@ export type Role = {
42184218
| "tracecat-service"
42194219
| "tracecat-ui"
42204220
is_platform_superuser?: boolean
4221+
scopes?: Array<string>
42214222
}
42224223

42234224
export type type3 = "user" | "service"

tests/unit/test_rbac_scopes.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66

7+
from tracecat.auth.types import Role
78
from tracecat.authz.controls import (
89
get_missing_scopes,
910
has_all_scopes,
@@ -24,10 +25,16 @@
2425
PRESET_ROLE_SCOPES,
2526
VIEWER_SCOPES,
2627
)
27-
from tracecat.contexts import ctx_scopes
28+
from tracecat.contexts import ctx_role
2829
from tracecat.exceptions import ScopeDeniedError
2930

3031

32+
def _set_role_with_scopes(scopes: frozenset[str]) -> None:
33+
"""Helper to set ctx_role with the given scopes."""
34+
role = Role(type="user", service_id="tracecat-api", scopes=scopes)
35+
ctx_role.set(role)
36+
37+
3138
class TestValidateScopeString:
3239
"""Tests for scope string validation."""
3340

@@ -228,7 +235,7 @@ class TestRequireScopeDecorator:
228235
"""Tests for the @require_scope decorator."""
229236

230237
def test_require_scope_passes_with_exact_scope(self):
231-
ctx_scopes.set(frozenset({"workflow:read"}))
238+
_set_role_with_scopes(frozenset({"workflow:read"}))
232239

233240
@require_scope("workflow:read")
234241
def protected_func():
@@ -237,7 +244,7 @@ def protected_func():
237244
assert protected_func() == "success"
238245

239246
def test_require_scope_passes_with_wildcard(self):
240-
ctx_scopes.set(frozenset({"workflow:*"}))
247+
_set_role_with_scopes(frozenset({"workflow:*"}))
241248

242249
@require_scope("workflow:read")
243250
def protected_func():
@@ -246,7 +253,7 @@ def protected_func():
246253
assert protected_func() == "success"
247254

248255
def test_require_scope_passes_with_superuser(self):
249-
ctx_scopes.set(frozenset({"*"}))
256+
_set_role_with_scopes(frozenset({"*"}))
250257

251258
@require_scope("org:delete")
252259
def protected_func():
@@ -255,7 +262,7 @@ def protected_func():
255262
assert protected_func() == "success"
256263

257264
def test_require_scope_fails_without_scope(self):
258-
ctx_scopes.set(frozenset({"case:read"}))
265+
_set_role_with_scopes(frozenset({"case:read"}))
259266

260267
@require_scope("workflow:read")
261268
def protected_func():
@@ -268,7 +275,7 @@ def protected_func():
268275
assert "workflow:read" in exc_info.value.missing_scopes
269276

270277
def test_require_scope_multiple_all_required(self):
271-
ctx_scopes.set(frozenset({"workflow:read", "workflow:execute"}))
278+
_set_role_with_scopes(frozenset({"workflow:read", "workflow:execute"}))
272279

273280
@require_scope("workflow:read", "workflow:execute", require_all=True)
274281
def protected_func():
@@ -277,7 +284,7 @@ def protected_func():
277284
assert protected_func() == "success"
278285

279286
def test_require_scope_multiple_missing_one(self):
280-
ctx_scopes.set(frozenset({"workflow:read"}))
287+
_set_role_with_scopes(frozenset({"workflow:read"}))
281288

282289
@require_scope("workflow:read", "workflow:execute", require_all=True)
283290
def protected_func():
@@ -289,7 +296,7 @@ def protected_func():
289296
assert "workflow:execute" in exc_info.value.missing_scopes
290297

291298
def test_require_scope_any_one_sufficient(self):
292-
ctx_scopes.set(frozenset({"workflow:read"}))
299+
_set_role_with_scopes(frozenset({"workflow:read"}))
293300

294301
@require_scope("workflow:read", "workflow:execute", require_all=False)
295302
def protected_func():
@@ -298,7 +305,7 @@ def protected_func():
298305
assert protected_func() == "success"
299306

300307
def test_require_scope_any_none_present(self):
301-
ctx_scopes.set(frozenset({"case:read"}))
308+
_set_role_with_scopes(frozenset({"case:read"}))
302309

303310
@require_scope("workflow:read", "workflow:execute", require_all=False)
304311
def protected_func():
@@ -307,9 +314,22 @@ def protected_func():
307314
with pytest.raises(ScopeDeniedError):
308315
protected_func()
309316

317+
def test_require_scope_fails_without_role(self):
318+
"""Test that require_scope fails when ctx_role is None."""
319+
ctx_role.set(None)
320+
321+
@require_scope("workflow:read")
322+
def protected_func():
323+
return "success"
324+
325+
with pytest.raises(ScopeDeniedError) as exc_info:
326+
protected_func()
327+
328+
assert "workflow:read" in exc_info.value.required_scopes
329+
310330
@pytest.mark.anyio
311331
async def test_require_scope_async_function(self):
312-
ctx_scopes.set(frozenset({"workflow:read"}))
332+
_set_role_with_scopes(frozenset({"workflow:read"}))
313333

314334
@require_scope("workflow:read")
315335
async def async_protected_func():
@@ -320,7 +340,7 @@ async def async_protected_func():
320340

321341
@pytest.mark.anyio
322342
async def test_require_scope_async_function_denied(self):
323-
ctx_scopes.set(frozenset({"case:read"}))
343+
_set_role_with_scopes(frozenset({"case:read"}))
324344

325345
@require_scope("workflow:read")
326346
async def async_protected_func():

tracecat/auth/credentials.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from tracecat.authz.enums import OrgRole, WorkspaceRole
3535
from tracecat.authz.scopes import ORG_ROLE_SCOPES, PRESET_ROLE_SCOPES
3636
from tracecat.authz.service import MembershipService, MembershipWithOrg
37-
from tracecat.contexts import ctx_role, ctx_scopes
37+
from tracecat.contexts import ctx_role
3838
from tracecat.db.dependencies import AsyncDBSession
3939
from tracecat.db.engine import get_async_session_context_manager
4040
from tracecat.db.models import Organization, OrganizationMembership, User, Workspace
@@ -202,6 +202,10 @@ async def _authenticate_service(
202202
if (org_role_str := request.headers.get("x-tracecat-role-org-role")) is not None
203203
else None
204204
)
205+
# Parse scopes from header if present (for inter-service calls)
206+
scopes: frozenset[str] = frozenset()
207+
if scopes_header := request.headers.get("x-tracecat-role-scopes"):
208+
scopes = frozenset(s.strip() for s in scopes_header.split(",") if s.strip())
205209
service_id: InternalServiceID = service_role_id # type: ignore[assignment]
206210
return Role(
207211
type="service",
@@ -211,6 +215,7 @@ async def _authenticate_service(
211215
workspace_id=workspace_id,
212216
workspace_role=workspace_role,
213217
org_role=org_role,
218+
scopes=scopes,
214219
)
215220

216221

@@ -625,15 +630,14 @@ def _validate_role(
625630
status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden"
626631
)
627632

628-
# Compute and set effective scopes
633+
# Compute effective scopes and create new role with scopes included
629634
scopes = compute_effective_scopes(role)
630-
ctx_scopes.set(scopes)
631635
logger.debug(
632636
"Computed effective scopes",
633637
scope_count=len(scopes),
634638
)
635639

636-
return role
640+
return role.model_copy(update={"scopes": scopes})
637641

638642

639643
# --- Main Auth Orchestrator ---
@@ -928,7 +932,7 @@ async def _authenticated_user_only(
928932
# organization_id intentionally None - user may not belong to any org
929933
)
930934
scopes = compute_effective_scopes(role)
931-
ctx_scopes.set(scopes)
935+
role = role.model_copy(update={"scopes": scopes})
932936
ctx_role.set(role)
933937
return role
934938

tracecat/auth/types.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ class Role(BaseModel):
5858
service_id: InternalServiceID = Field(frozen=True)
5959
is_platform_superuser: bool = Field(default=False, frozen=True)
6060
"""Whether this role belongs to a platform superuser (User.is_superuser=True)."""
61+
scopes: frozenset[str] = Field(default=frozenset(), frozen=True)
62+
"""Effective scopes for this role. Computed during authentication."""
6163

6264
@property
6365
def is_superuser(self) -> bool:
@@ -95,6 +97,8 @@ def to_headers(self) -> dict[str, str]:
9597
headers["x-tracecat-role-workspace-role"] = self.workspace_role.value
9698
if self.org_role is not None:
9799
headers["x-tracecat-role-org-role"] = self.org_role.value
100+
if self.scopes:
101+
headers["x-tracecat-role-scopes"] = ",".join(sorted(self.scopes))
98102
return headers
99103

100104

@@ -125,5 +129,5 @@ def system_role() -> Role:
125129
type="service",
126130
service_id="tracecat-api",
127131
access_level=AccessLevel.ADMIN,
128-
is_platform_superuser=True,
132+
scopes=frozenset({"*"}),
129133
)

tracecat/authz/controls.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import functools
33
import re
44
from collections.abc import Callable, Coroutine
5+
from fnmatch import fnmatch
56
from typing import Any, Protocol, TypeVar, cast, runtime_checkable
67

7-
from tracecat.auth.types import AccessLevel, Role
8-
from tracecat.authz.enums import WorkspaceRole
9-
from tracecat.contexts import ctx_scopes
8+
from tracecat.auth.types import Role
9+
from tracecat.authz.enums import OrgRole, WorkspaceRole
10+
from tracecat.contexts import ctx_role
1011
from tracecat.exceptions import ScopeDeniedError, TracecatAuthorizationError
1112
from tracecat.logger import logger
1213

@@ -56,10 +57,8 @@ def scope_matches(granted_scope: str, required_scope: str) -> bool:
5657
# No wildcard - exact match required
5758
return granted_scope == required_scope
5859

59-
# Convert fnmatch-style pattern to regex
60-
# Escape all regex special chars except *, then convert * to .*
61-
pattern = re.escape(granted_scope).replace(r"\*", ".*")
62-
return bool(re.fullmatch(pattern, required_scope))
60+
# Use fnmatch for wildcard matching (avoids regex backtracking issues)
61+
return fnmatch(required_scope, granted_scope)
6362

6463

6564
def has_scope(user_scopes: frozenset[str], required_scope: str) -> bool:
@@ -242,8 +241,8 @@ def wrapper(self, *args, **kwargs):
242241
def require_scope(*scopes: str, require_all: bool = True) -> Callable[[T], T]:
243242
"""Decorator that requires specific scopes to access an endpoint or method.
244243
245-
This decorator checks the current request's scopes (from ctx_scopes) against
246-
the required scopes. Platform superusers with the "*" scope bypass all checks.
244+
This decorator checks the current request's scopes (from ctx_role.get().scopes)
245+
against the required scopes. Platform superusers with the "*" scope bypass all checks.
247246
248247
Args:
249248
*scopes: The scope(s) required for access (e.g., "workflow:read", "org:member:invite")
@@ -276,7 +275,13 @@ def check_scopes():
276275
if not required:
277276
return
278277

279-
user_scopes = ctx_scopes.get()
278+
role = ctx_role.get()
279+
if role is None:
280+
raise ScopeDeniedError(
281+
required_scopes=list(required), missing_scopes=list(required)
282+
)
283+
284+
user_scopes = role.scopes
280285

281286
# Platform superuser has "*" scope - bypass all checks
282287
if "*" in user_scopes:

tracecat/contexts.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
__all__ = [
1616
"ctx_run",
1717
"ctx_role",
18-
"ctx_scopes",
1918
"ctx_logger",
2019
"ctx_interaction",
2120
"ctx_stream_id",
@@ -27,8 +26,6 @@
2726

2827
ctx_run: ContextVar[RunContext | None] = ContextVar("run", default=None)
2928
ctx_role: ContextVar[Role | None] = ContextVar("role", default=None)
30-
ctx_scopes: ContextVar[frozenset[str]] = ContextVar("scopes", default=frozenset())
31-
"""Effective scopes for the current request. Computed during authentication."""
3229
ctx_logger: ContextVar[loguru.Logger | None] = ContextVar("logger", default=None)
3330
ctx_interaction: ContextVar[InteractionContext | None] = ContextVar(
3431
"interaction", default=None

0 commit comments

Comments
 (0)