Skip to content

Commit 66302e5

Browse files
committed
feat(rbac): add rbac auth layer
1 parent 1b9c4aa commit 66302e5

File tree

7 files changed

+881
-3
lines changed

7 files changed

+881
-3
lines changed

tests/unit/test_rbac_scopes.py

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
"""Unit tests for RBAC scope matching and controls."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from tracecat.authz.controls import (
8+
get_missing_scopes,
9+
has_all_scopes,
10+
has_any_scope,
11+
has_scope,
12+
require_scope,
13+
scope_matches,
14+
validate_scope_string,
15+
)
16+
from tracecat.authz.enums import OrgRole, WorkspaceRole
17+
from tracecat.authz.scopes import (
18+
ADMIN_SCOPES,
19+
EDITOR_SCOPES,
20+
ORG_ADMIN_SCOPES,
21+
ORG_MEMBER_SCOPES,
22+
ORG_OWNER_SCOPES,
23+
ORG_ROLE_SCOPES,
24+
SYSTEM_ROLE_SCOPES,
25+
VIEWER_SCOPES,
26+
)
27+
from tracecat.contexts import ctx_scopes
28+
from tracecat.exceptions import ScopeDeniedError
29+
30+
31+
class TestValidateScopeString:
32+
"""Tests for scope string validation."""
33+
34+
def test_valid_simple_scope(self):
35+
assert validate_scope_string("workflow:read") is True
36+
assert validate_scope_string("case:create") is True
37+
assert validate_scope_string("org:member:invite") is True
38+
39+
def test_valid_scope_with_wildcard(self):
40+
assert validate_scope_string("workflow:*") is True
41+
assert validate_scope_string("action:*:execute") is True
42+
assert validate_scope_string("*") is True
43+
44+
def test_valid_scope_with_special_chars(self):
45+
assert validate_scope_string("action:core.http_request:execute") is True
46+
assert validate_scope_string("action:tools.okta-client:execute") is True
47+
48+
def test_invalid_scope_uppercase(self):
49+
assert validate_scope_string("Workflow:read") is False
50+
assert validate_scope_string("WORKFLOW:READ") is False
51+
52+
def test_invalid_scope_spaces(self):
53+
assert validate_scope_string("workflow: read") is False
54+
assert validate_scope_string("workflow :read") is False
55+
56+
def test_invalid_scope_other_patterns(self):
57+
assert validate_scope_string("workflow:?") is False
58+
assert validate_scope_string("workflow:[read]") is False
59+
60+
61+
class TestScopeMatches:
62+
"""Tests for scope matching with wildcards."""
63+
64+
def test_exact_match(self):
65+
assert scope_matches("workflow:read", "workflow:read") is True
66+
assert scope_matches("workflow:read", "workflow:create") is False
67+
68+
def test_global_wildcard(self):
69+
assert scope_matches("*", "workflow:read") is True
70+
assert scope_matches("*", "org:member:invite") is True
71+
assert scope_matches("*", "anything:here") is True
72+
73+
def test_suffix_wildcard(self):
74+
assert scope_matches("workflow:*", "workflow:read") is True
75+
assert scope_matches("workflow:*", "workflow:create") is True
76+
assert scope_matches("workflow:*", "workflow:execute") is True
77+
assert scope_matches("workflow:*", "case:read") is False
78+
79+
def test_middle_wildcard(self):
80+
assert scope_matches("action:*:execute", "action:core.http:execute") is True
81+
assert scope_matches("action:*:execute", "action:tools.okta:execute") is True
82+
assert scope_matches("action:*:execute", "action:tools.okta:read") is False
83+
84+
def test_prefix_wildcard(self):
85+
assert (
86+
scope_matches("action:core.*:execute", "action:core.http:execute") is True
87+
)
88+
assert (
89+
scope_matches("action:core.*:execute", "action:core.transform:execute")
90+
is True
91+
)
92+
assert (
93+
scope_matches("action:core.*:execute", "action:tools.okta:execute") is False
94+
)
95+
96+
def test_multiple_wildcards(self):
97+
# Multiple wildcards in a scope
98+
assert scope_matches("action:*.*:execute", "action:core.http:execute") is True
99+
assert scope_matches("*:*", "workflow:read") is True
100+
101+
102+
class TestHasScope:
103+
"""Tests for has_scope function."""
104+
105+
def test_has_exact_scope(self):
106+
scopes = frozenset({"workflow:read", "case:create"})
107+
assert has_scope(scopes, "workflow:read") is True
108+
assert has_scope(scopes, "case:create") is True
109+
assert has_scope(scopes, "workflow:delete") is False
110+
111+
def test_has_scope_via_wildcard(self):
112+
scopes = frozenset({"workflow:*", "case:read"})
113+
assert has_scope(scopes, "workflow:read") is True
114+
assert has_scope(scopes, "workflow:create") is True
115+
assert has_scope(scopes, "workflow:delete") is True
116+
assert has_scope(scopes, "case:read") is True
117+
assert has_scope(scopes, "case:create") is False
118+
119+
def test_has_scope_global_wildcard(self):
120+
scopes = frozenset({"*"})
121+
assert has_scope(scopes, "workflow:read") is True
122+
assert has_scope(scopes, "org:delete") is True
123+
assert has_scope(scopes, "anything:here") is True
124+
125+
def test_empty_scopes(self):
126+
scopes: frozenset[str] = frozenset()
127+
assert has_scope(scopes, "workflow:read") is False
128+
129+
130+
class TestHasAllScopes:
131+
"""Tests for has_all_scopes function."""
132+
133+
def test_has_all_exact_scopes(self):
134+
scopes = frozenset({"workflow:read", "workflow:execute", "case:read"})
135+
assert has_all_scopes(scopes, {"workflow:read", "workflow:execute"}) is True
136+
assert has_all_scopes(scopes, {"workflow:read", "case:create"}) is False
137+
138+
def test_has_all_via_wildcard(self):
139+
scopes = frozenset({"workflow:*"})
140+
assert has_all_scopes(scopes, {"workflow:read", "workflow:execute"}) is True
141+
assert has_all_scopes(scopes, {"workflow:read", "case:read"}) is False
142+
143+
144+
class TestHasAnyScope:
145+
"""Tests for has_any_scope function."""
146+
147+
def test_has_any_exact_scope(self):
148+
scopes = frozenset({"workflow:read", "case:create"})
149+
assert has_any_scope(scopes, {"workflow:read", "workflow:delete"}) is True
150+
assert has_any_scope(scopes, {"org:delete", "secret:read"}) is False
151+
152+
def test_has_any_via_wildcard(self):
153+
scopes = frozenset({"workflow:*"})
154+
assert has_any_scope(scopes, {"workflow:read", "case:read"}) is True
155+
assert has_any_scope(scopes, {"case:read", "org:read"}) is False
156+
157+
158+
class TestGetMissingScopes:
159+
"""Tests for get_missing_scopes function."""
160+
161+
def test_no_missing_scopes(self):
162+
scopes = frozenset({"workflow:read", "workflow:execute"})
163+
missing = get_missing_scopes(scopes, {"workflow:read", "workflow:execute"})
164+
assert missing == set()
165+
166+
def test_some_missing_scopes(self):
167+
scopes = frozenset({"workflow:read"})
168+
missing = get_missing_scopes(scopes, {"workflow:read", "workflow:execute"})
169+
assert missing == {"workflow:execute"}
170+
171+
def test_all_missing_scopes(self):
172+
scopes = frozenset({"case:read"})
173+
missing = get_missing_scopes(scopes, {"workflow:read", "workflow:execute"})
174+
assert missing == {"workflow:read", "workflow:execute"}
175+
176+
def test_wildcard_covers_scopes(self):
177+
scopes = frozenset({"workflow:*"})
178+
missing = get_missing_scopes(scopes, {"workflow:read", "workflow:execute"})
179+
assert missing == set()
180+
181+
182+
class TestSystemRoleScopes:
183+
"""Tests for system role scope definitions."""
184+
185+
def test_viewer_scopes_are_read_only(self):
186+
for scope in VIEWER_SCOPES:
187+
# Viewer should only have read scopes (no create, update, delete, execute)
188+
action = scope.split(":")[-1]
189+
assert action in ("read", "member:read"), f"Unexpected scope: {scope}"
190+
191+
def test_editor_includes_viewer(self):
192+
assert VIEWER_SCOPES.issubset(EDITOR_SCOPES)
193+
194+
def test_admin_includes_editor(self):
195+
assert EDITOR_SCOPES.issubset(ADMIN_SCOPES)
196+
197+
def test_system_role_mapping(self):
198+
assert SYSTEM_ROLE_SCOPES[WorkspaceRole.VIEWER] == VIEWER_SCOPES
199+
assert SYSTEM_ROLE_SCOPES[WorkspaceRole.EDITOR] == EDITOR_SCOPES
200+
assert SYSTEM_ROLE_SCOPES[WorkspaceRole.ADMIN] == ADMIN_SCOPES
201+
202+
203+
class TestOrgRoleScopes:
204+
"""Tests for organization role scope definitions."""
205+
206+
def test_owner_has_org_delete(self):
207+
assert "org:delete" in ORG_OWNER_SCOPES
208+
assert "org:delete" not in ORG_ADMIN_SCOPES
209+
assert "org:delete" not in ORG_MEMBER_SCOPES
210+
211+
def test_owner_has_billing_manage(self):
212+
assert "org:billing:manage" in ORG_OWNER_SCOPES
213+
assert "org:billing:manage" not in ORG_ADMIN_SCOPES
214+
215+
def test_admin_has_billing_read(self):
216+
assert "org:billing:read" in ORG_ADMIN_SCOPES
217+
218+
def test_member_has_minimal_scopes(self):
219+
assert ORG_MEMBER_SCOPES == frozenset({"org:read", "org:member:read"})
220+
221+
def test_org_role_mapping(self):
222+
assert ORG_ROLE_SCOPES[OrgRole.OWNER] == ORG_OWNER_SCOPES
223+
assert ORG_ROLE_SCOPES[OrgRole.ADMIN] == ORG_ADMIN_SCOPES
224+
assert ORG_ROLE_SCOPES[OrgRole.MEMBER] == ORG_MEMBER_SCOPES
225+
226+
227+
class TestRequireScopeDecorator:
228+
"""Tests for the @require_scope decorator."""
229+
230+
def test_require_scope_passes_with_exact_scope(self):
231+
ctx_scopes.set(frozenset({"workflow:read"}))
232+
233+
@require_scope("workflow:read")
234+
def protected_func():
235+
return "success"
236+
237+
assert protected_func() == "success"
238+
239+
def test_require_scope_passes_with_wildcard(self):
240+
ctx_scopes.set(frozenset({"workflow:*"}))
241+
242+
@require_scope("workflow:read")
243+
def protected_func():
244+
return "success"
245+
246+
assert protected_func() == "success"
247+
248+
def test_require_scope_passes_with_superuser(self):
249+
ctx_scopes.set(frozenset({"*"}))
250+
251+
@require_scope("org:delete")
252+
def protected_func():
253+
return "success"
254+
255+
assert protected_func() == "success"
256+
257+
def test_require_scope_fails_without_scope(self):
258+
ctx_scopes.set(frozenset({"case:read"}))
259+
260+
@require_scope("workflow:read")
261+
def protected_func():
262+
return "success"
263+
264+
with pytest.raises(ScopeDeniedError) as exc_info:
265+
protected_func()
266+
267+
assert "workflow:read" in exc_info.value.required_scopes
268+
assert "workflow:read" in exc_info.value.missing_scopes
269+
270+
def test_require_scope_multiple_all_required(self):
271+
ctx_scopes.set(frozenset({"workflow:read", "workflow:execute"}))
272+
273+
@require_scope("workflow:read", "workflow:execute", require_all=True)
274+
def protected_func():
275+
return "success"
276+
277+
assert protected_func() == "success"
278+
279+
def test_require_scope_multiple_missing_one(self):
280+
ctx_scopes.set(frozenset({"workflow:read"}))
281+
282+
@require_scope("workflow:read", "workflow:execute", require_all=True)
283+
def protected_func():
284+
return "success"
285+
286+
with pytest.raises(ScopeDeniedError) as exc_info:
287+
protected_func()
288+
289+
assert "workflow:execute" in exc_info.value.missing_scopes
290+
291+
def test_require_scope_any_one_sufficient(self):
292+
ctx_scopes.set(frozenset({"workflow:read"}))
293+
294+
@require_scope("workflow:read", "workflow:execute", require_all=False)
295+
def protected_func():
296+
return "success"
297+
298+
assert protected_func() == "success"
299+
300+
def test_require_scope_any_none_present(self):
301+
ctx_scopes.set(frozenset({"case:read"}))
302+
303+
@require_scope("workflow:read", "workflow:execute", require_all=False)
304+
def protected_func():
305+
return "success"
306+
307+
with pytest.raises(ScopeDeniedError):
308+
protected_func()
309+
310+
@pytest.mark.anyio
311+
async def test_require_scope_async_function(self):
312+
ctx_scopes.set(frozenset({"workflow:read"}))
313+
314+
@require_scope("workflow:read")
315+
async def async_protected_func():
316+
return "async success"
317+
318+
result = await async_protected_func()
319+
assert result == "async success"
320+
321+
@pytest.mark.anyio
322+
async def test_require_scope_async_function_denied(self):
323+
ctx_scopes.set(frozenset({"case:read"}))
324+
325+
@require_scope("workflow:read")
326+
async def async_protected_func():
327+
return "async success"
328+
329+
with pytest.raises(ScopeDeniedError):
330+
await async_protected_func()

tracecat/api/app.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
from tracecat.db.dependencies import AsyncDBSession
6969
from tracecat.db.engine import get_async_session_context_manager
7070
from tracecat.editor.router import router as editor_router
71-
from tracecat.exceptions import EntitlementRequired, TracecatException
71+
from tracecat.exceptions import EntitlementRequired, ScopeDeniedError, TracecatException
7272
from tracecat.feature_flags import (
7373
FeatureFlag,
7474
FlagLike,
@@ -274,6 +274,40 @@ def entitlement_exception_handler(request: Request, exc: Exception) -> Response:
274274
)
275275

276276

277+
def scope_denied_exception_handler(request: Request, exc: Exception) -> Response:
278+
"""Handle ScopeDeniedError exceptions with a 403 Forbidden response.
279+
280+
Returns a machine-readable error response with:
281+
- code: "insufficient_scope"
282+
- message: Human-readable error message
283+
- required_scopes: Scopes that were required for the operation
284+
- missing_scopes: Scopes that the user was missing
285+
"""
286+
if not isinstance(exc, ScopeDeniedError):
287+
return ORJSONResponse(
288+
status_code=status.HTTP_403_FORBIDDEN,
289+
content={"detail": str(exc)},
290+
)
291+
logger.warning(
292+
"Scope denied",
293+
required_scopes=exc.required_scopes,
294+
missing_scopes=exc.missing_scopes,
295+
path=request.url.path,
296+
role=ctx_role.get(),
297+
)
298+
return ORJSONResponse(
299+
status_code=status.HTTP_403_FORBIDDEN,
300+
content={
301+
"error": {
302+
"code": "insufficient_scope",
303+
"message": str(exc),
304+
"required_scopes": exc.required_scopes,
305+
"missing_scopes": exc.missing_scopes,
306+
}
307+
},
308+
)
309+
310+
277311
def feature_flag_dep(flag: FlagLike) -> Callable[..., None]:
278312
"""Check if a feature flag is enabled."""
279313

@@ -454,6 +488,7 @@ def create_app(**kwargs) -> FastAPI:
454488
fastapi_users_auth_exception_handler,
455489
)
456490
app.add_exception_handler(EntitlementRequired, entitlement_exception_handler)
491+
app.add_exception_handler(ScopeDeniedError, scope_denied_exception_handler)
457492

458493
# Middleware
459494
# Add authorization cache middleware first so it's available for all requests

0 commit comments

Comments
 (0)