Skip to content

Commit 471896f

Browse files
committed
feat(rbac): add @require_scope decorators to all API endpoints
1 parent 25fe8c2 commit 471896f

File tree

25 files changed

+429
-78
lines changed

25 files changed

+429
-78
lines changed

tests/unit/test_rbac_scopes.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
has_all_scopes,
1010
has_any_scope,
1111
has_scope,
12+
require_action_scope,
1213
require_scope,
1314
scope_matches,
1415
validate_scope_string,
@@ -328,3 +329,81 @@ async def async_protected_func():
328329

329330
with pytest.raises(ScopeDeniedError):
330331
await async_protected_func()
332+
333+
334+
class TestRequireActionScope:
335+
"""Tests for the require_action_scope function."""
336+
337+
def test_require_action_scope_with_exact_scope(self):
338+
"""User with exact action scope can execute."""
339+
ctx_scopes.set(frozenset({"action:core.http_request:execute"}))
340+
# Should not raise
341+
require_action_scope("core.http_request")
342+
343+
def test_require_action_scope_with_global_wildcard(self):
344+
"""Superuser with * scope can execute any action."""
345+
ctx_scopes.set(frozenset({"*"}))
346+
require_action_scope("core.http_request")
347+
require_action_scope("tools.okta.list_users")
348+
349+
def test_require_action_scope_with_action_wildcard(self):
350+
"""User with action:*:execute can execute any action."""
351+
ctx_scopes.set(frozenset({"action:*:execute"}))
352+
require_action_scope("core.http_request")
353+
require_action_scope("tools.okta.list_users")
354+
355+
def test_require_action_scope_with_prefix_wildcard(self):
356+
"""User with action:core.*:execute can execute core actions."""
357+
ctx_scopes.set(frozenset({"action:core.*:execute"}))
358+
require_action_scope("core.http_request")
359+
require_action_scope("core.transform.forward")
360+
361+
# Should fail for non-core actions
362+
with pytest.raises(ScopeDeniedError) as exc_info:
363+
require_action_scope("tools.okta.list_users")
364+
assert "action:tools.okta.list_users:execute" in exc_info.value.missing_scopes
365+
366+
def test_require_action_scope_with_integration_wildcard(self):
367+
"""User with action:tools.okta.*:execute can execute okta actions."""
368+
ctx_scopes.set(frozenset({"action:tools.okta.*:execute"}))
369+
require_action_scope("tools.okta.list_users")
370+
require_action_scope("tools.okta.suspend_user")
371+
372+
# Should fail for other integrations
373+
with pytest.raises(ScopeDeniedError):
374+
require_action_scope("tools.slack.send_message")
375+
376+
def test_require_action_scope_denied(self):
377+
"""User without action scope gets denied."""
378+
ctx_scopes.set(frozenset({"workflow:execute"}))
379+
380+
with pytest.raises(ScopeDeniedError) as exc_info:
381+
require_action_scope("core.http_request")
382+
383+
assert exc_info.value.required_scopes == ["action:core.http_request:execute"]
384+
assert exc_info.value.missing_scopes == ["action:core.http_request:execute"]
385+
386+
def test_require_action_scope_empty_scopes(self):
387+
"""User with no scopes gets denied."""
388+
ctx_scopes.set(frozenset())
389+
390+
with pytest.raises(ScopeDeniedError):
391+
require_action_scope("core.http_request")
392+
393+
def test_require_action_scope_multiple_scopes(self):
394+
"""User with multiple action scopes can execute matching actions."""
395+
ctx_scopes.set(
396+
frozenset(
397+
{
398+
"action:core.*:execute",
399+
"action:tools.okta.*:execute",
400+
"workflow:execute",
401+
}
402+
)
403+
)
404+
require_action_scope("core.http_request")
405+
require_action_scope("tools.okta.list_users")
406+
407+
# Should fail for non-matching actions
408+
with pytest.raises(ScopeDeniedError):
409+
require_action_scope("tools.slack.send_message")

tracecat/agent/router.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tracecat.agent.service import AgentManagementService
1212
from tracecat.auth.credentials import RoleACL
1313
from tracecat.auth.types import AccessLevel, Role
14+
from tracecat.authz.controls import require_scope
1415
from tracecat.db.dependencies import AsyncDBSession
1516
from tracecat.exceptions import TracecatNotFoundError
1617

@@ -46,6 +47,7 @@
4647

4748

4849
@router.get("/models")
50+
@require_scope("agent:read")
4951
async def list_models(
5052
*,
5153
role: OrganizationUserRole,
@@ -57,6 +59,7 @@ async def list_models(
5759

5860

5961
@router.get("/providers")
62+
@require_scope("agent:read")
6063
async def list_providers(
6164
*,
6265
role: OrganizationUserRole,
@@ -68,6 +71,7 @@ async def list_providers(
6871

6972

7073
@router.get("/providers/status")
74+
@require_scope("agent:read")
7175
async def get_providers_status(
7276
*,
7377
role: OrganizationUserRole,
@@ -79,6 +83,7 @@ async def get_providers_status(
7983

8084

8185
@router.get("/providers/configs")
86+
@require_scope("agent:read")
8287
async def list_provider_credential_configs(
8388
*,
8489
role: OrganizationAdminUserRole,
@@ -90,6 +95,7 @@ async def list_provider_credential_configs(
9095

9196

9297
@router.get("/providers/{provider}/config")
98+
@require_scope("agent:read")
9399
async def get_provider_credential_config(
94100
*,
95101
provider: str,
@@ -108,6 +114,7 @@ async def get_provider_credential_config(
108114

109115

110116
@router.post("/credentials", status_code=status.HTTP_201_CREATED)
117+
@require_scope("agent:execute")
111118
async def create_provider_credentials(
112119
*,
113120
params: ModelCredentialCreate,
@@ -127,6 +134,7 @@ async def create_provider_credentials(
127134

128135

129136
@router.put("/credentials/{provider}")
137+
@require_scope("agent:update")
130138
async def update_provider_credentials(
131139
*,
132140
provider: str,
@@ -152,6 +160,7 @@ async def update_provider_credentials(
152160

153161

154162
@router.delete("/credentials/{provider}")
163+
@require_scope("agent:delete")
155164
async def delete_provider_credentials(
156165
*,
157166
provider: str,
@@ -165,6 +174,7 @@ async def delete_provider_credentials(
165174

166175

167176
@router.get("/default-model")
177+
@require_scope("agent:read")
168178
async def get_default_model(
169179
*,
170180
role: OrganizationUserRole,
@@ -176,6 +186,7 @@ async def get_default_model(
176186

177187

178188
@router.put("/default-model")
189+
@require_scope("agent:update")
179190
async def set_default_model(
180191
*,
181192
model_name: str,
@@ -200,6 +211,7 @@ async def set_default_model(
200211

201212

202213
@router.get("/workspace/providers/status")
214+
@require_scope("agent:read")
203215
async def get_workspace_providers_status(
204216
*,
205217
role: WorkspaceUserRole,

tracecat/authz/controls.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,50 @@ def wrapper(self, *args, **kwargs):
225225
# =============================================================================
226226

227227

228+
def require_action_scope(action_key: str) -> None:
229+
"""Check if the current user has permission to execute a specific action.
230+
231+
This function checks the context scopes against the required action scope.
232+
The required scope is `action:{action_key}:execute`.
233+
234+
Scope matching supports wildcards:
235+
- `action:*:execute` → any action (Admin)
236+
- `action:core.*:execute` → core actions (Editor)
237+
- `action:tools.okta.*:execute` → okta actions (custom role)
238+
- `action:tools.okta.list_users:execute` → specific action
239+
240+
Args:
241+
action_key: The action key (e.g., "core.http_request", "tools.okta.list_users")
242+
243+
Raises:
244+
ScopeDeniedError: If the user doesn't have permission to execute the action
245+
"""
246+
user_scopes = ctx_scopes.get()
247+
248+
# Platform superuser has "*" scope - bypass all checks
249+
if "*" in user_scopes:
250+
return
251+
252+
required_scope = f"action:{action_key}:execute"
253+
254+
if not has_scope(user_scopes, required_scope):
255+
logger.warning(
256+
"Action scope check failed",
257+
action_key=action_key,
258+
required_scope=required_scope,
259+
)
260+
raise ScopeDeniedError(
261+
required_scopes=[required_scope],
262+
missing_scopes=[required_scope],
263+
)
264+
265+
logger.debug(
266+
"Action scope check passed",
267+
action_key=action_key,
268+
required_scope=required_scope,
269+
)
270+
271+
228272
def require_scope(*scopes: str, require_all: bool = True) -> Callable[[T], T]:
229273
"""Decorator that requires specific scopes to access an endpoint or method.
230274

tracecat/authz/scopes.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"schedule:read",
3131
"agent:read",
3232
"secret:read",
33+
"tag:read",
34+
"variable:read",
3335
"workspace:read",
3436
"workspace:member:read",
3537
}
@@ -53,6 +55,10 @@
5355
"agent:execute",
5456
"secret:create",
5557
"secret:update",
58+
"tag:create",
59+
"tag:update",
60+
"variable:create",
61+
"variable:update",
5662
# Core actions available to editors
5763
"action:core.*:execute",
5864
}
@@ -69,6 +75,8 @@
6975
"table:delete",
7076
"schedule:delete",
7177
"secret:delete",
78+
"tag:delete",
79+
"variable:delete",
7280
"agent:create",
7381
"agent:update",
7482
"agent:delete",
@@ -116,6 +124,9 @@
116124
# RBAC management
117125
"org:rbac:read",
118126
"org:rbac:manage",
127+
# Org settings management
128+
"org:settings:read",
129+
"org:settings:manage",
119130
# Full workspace control across the org
120131
"workspace:read",
121132
"workspace:create",
@@ -139,6 +150,14 @@
139150
"table:create",
140151
"table:update",
141152
"table:delete",
153+
"tag:read",
154+
"tag:create",
155+
"tag:update",
156+
"tag:delete",
157+
"variable:read",
158+
"variable:create",
159+
"variable:update",
160+
"variable:delete",
142161
"schedule:read",
143162
"schedule:create",
144163
"schedule:update",
@@ -172,6 +191,9 @@
172191
# RBAC management
173192
"org:rbac:read",
174193
"org:rbac:manage",
194+
# Org settings management
195+
"org:settings:read",
196+
"org:settings:manage",
175197
# Full workspace control across the org
176198
"workspace:read",
177199
"workspace:create",
@@ -195,6 +217,14 @@
195217
"table:create",
196218
"table:update",
197219
"table:delete",
220+
"tag:read",
221+
"tag:create",
222+
"tag:update",
223+
"tag:delete",
224+
"variable:read",
225+
"variable:create",
226+
"variable:update",
227+
"variable:delete",
198228
"schedule:read",
199229
"schedule:create",
200230
"schedule:update",

tracecat/authz/seeding.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,19 @@
116116
("secret:create", "secret", "create", "Create new secrets"),
117117
("secret:update", "secret", "update", "Modify existing secrets"),
118118
("secret:delete", "secret", "delete", "Delete secrets"),
119+
# Tag scopes
120+
("tag:read", "tag", "read", "View tags"),
121+
("tag:create", "tag", "create", "Create new tags"),
122+
("tag:update", "tag", "update", "Modify existing tags"),
123+
("tag:delete", "tag", "delete", "Delete tags"),
124+
# Variable scopes
125+
("variable:read", "variable", "read", "View variables"),
126+
("variable:create", "variable", "create", "Create new variables"),
127+
("variable:update", "variable", "update", "Modify existing variables"),
128+
("variable:delete", "variable", "delete", "Delete variables"),
129+
# Organization settings scopes
130+
("org:settings:read", "org:settings", "read", "View organization settings"),
131+
("org:settings:manage", "org:settings", "manage", "Manage organization settings"),
119132
# Wildcard action scopes (for role assignments)
120133
("action:*:execute", "action", "execute", "Execute any registry action"),
121134
(

tracecat/cases/attachments/router.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Router for case attachments endpoints."""
22

3-
from __future__ import annotations
4-
53
import hashlib
64
import uuid
75
from typing import Annotated

0 commit comments

Comments
 (0)