Skip to content

Commit 6ec0902

Browse files
authored
feat(rbac): rbac commands in the sdk (#215)
* feat(models): Add Workspace and Organization models Introduce new data models for Workspace and Organization in dreadnode/api/models.py. Also updates the Project model to include a 'workspace_id'. * feat(api): Add methods for Organization and Workspace management Introduce four new client methods: list_organizations, get_organization, list_workspaces, and get_workspace. These methods handle CRUD-like operations for the newly introduced RBAC-related models. * feat(sdk): Integrate Organization and Workspace into Dreadnode client Adds support for specifying default Organization and Workspace via environment variables (DREADNODE_ORGANIZATION, DREADNODE_WORKSPACE) or initialization parameters. The client now includes logic to automatically select an organization and workspace if only one exists, and raises errors for ambiguity or non-existence, enforcing the new RBAC structure. * feat(api): Implement organization, workspace, and project resolution logic This commit refactors the initialization logic in `Dreadnode` to automatically resolve or create the current organization, workspace, and project based on configuration or defaults. The changes include: * **API Client Enhancements**: Added methods for `create_workspace`, `create_project`, and updated existing `list_projects`, `get_project`, `list_workspaces`, `get_organization`, and `get_workspace` to support filters, pagination (for workspaces), and UUID identifiers. * **RBAC Resolution**: New private methods (`_resolve_organization`, `_resolve_workspace`, `_resolve_project`, `_resolve_rbac`) handle the full resolution workflow, including default creation for workspaces and projects if they don't exist. * **Model Updates**: Introduced `WorkspaceFilter`, and `PaginatedWorkspaces` Pydantic models. * **RunSpan Update**: Renamed `project` attribute to `project_id` in `RunSpan` for clarity, reflecting the use of the ID in traces/spans. * **Constants**: Added `DEFAULT_WORKSPACE_NAME` and `DEFAULT_PROJECT_NAME`. This enables a much smoother initialization experience for users, automatically provisioning necessary resources. * chore: updates to fix typing * refactor(config): Improve organization, workspace, and project resolution Introduce robust logic for resolving organization, workspace, and project from configuration, environment variables, or path strings (e.g., 'org/ws/project'). Updates include: * Add organization/workspace to `DreadnodeConfig`. * `ApiClient.get_workspace` now optionally accepts `org_id`. * Project creation now uses 'org_id' in API payload. * Add `_extract_project_components` to parse project path format. * Centralize configuration logging with `_log_configuration`. * Renamed `Workspace.owner_id` to `Workspace.created_by` in models. * fix: updated regex to handle spaces * feat(otel): Include SDK version in OTLP User-Agent * refactor(workspace): remove automatic default workspace creation * fix(exporter): decode User-Agent bytes and add type hints * feat(rbac): new CLI commands for managing organizations and workspaces * feat(rbac): Add CLI commands for organizations and workspaces
1 parent 46aea6d commit 6ec0902

File tree

5 files changed

+151
-0
lines changed

5 files changed

+151
-0
lines changed

dreadnode/api/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,7 @@ def create_workspace(
868868
self,
869869
name: str,
870870
organization_id: UUID,
871+
description: str | None = None,
871872
) -> Workspace:
872873
"""
873874
Creates a new workspace.
@@ -882,8 +883,19 @@ def create_workspace(
882883

883884
payload = {
884885
"name": name,
886+
"description": description,
885887
"org_id": str(organization_id),
886888
}
887889

888890
response = self.request("POST", "/workspaces", json_data=payload)
889891
return Workspace(**response.json())
892+
893+
def delete_workspace(self, workspace_id: str | UUID) -> None:
894+
"""
895+
Deletes a specific workspace.
896+
897+
Args:
898+
workspace_id (str | UUID): The workspace identifier.
899+
"""
900+
901+
self.request("DELETE", f"/workspaces/{workspace_id!s}")

dreadnode/cli/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
)
2626
from dreadnode.cli.platform import cli as platform_cli
2727
from dreadnode.cli.profile import cli as profile_cli
28+
from dreadnode.cli.rbac.organizations import cli as rbac_organizations_cli
29+
from dreadnode.cli.rbac.workspaces import cli as rbac_workspaces_cli
2830
from dreadnode.cli.study import cli as study_cli
2931
from dreadnode.cli.task import cli as task_cli
3032
from dreadnode.constants import DEBUG, PLATFORM_BASE_URL
@@ -46,6 +48,8 @@
4648
cli.command(eval_cli)
4749
cli.command(study_cli)
4850
cli.command(attack_cli)
51+
cli.command(rbac_organizations_cli)
52+
cli.command(rbac_workspaces_cli)
4953
cli.command(platform_cli)
5054
cli.command(profile_cli)
5155

dreadnode/cli/rbac/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from dreadnode.cli.platform.cli import cli
2+
3+
__all__ = ["cli"]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import cyclopts
2+
3+
from dreadnode.cli.api import create_api_client
4+
from dreadnode.logging_ import print_info
5+
6+
cli = cyclopts.App("organizations", help="View and manage organizations.", help_flags=[])
7+
8+
9+
@cli.command(name=["list", "ls", "show"])
10+
def show() -> None:
11+
# get the client and call the list organizations endpoint
12+
client = create_api_client()
13+
organizations = client.list_organizations()
14+
for org in organizations:
15+
print_info(f"- {org.name} (ID: {org.id})")

dreadnode/cli/rbac/workspaces.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import cyclopts
2+
from click import confirm
3+
4+
from dreadnode.api.models import Organization, Workspace, WorkspaceFilter
5+
from dreadnode.cli.api import create_api_client
6+
from dreadnode.logging_ import print_error, print_info
7+
8+
cli = cyclopts.App("workspaces", help="View and manage workspaces.", help_flags=[])
9+
10+
11+
@cli.command(name=["list", "ls", "show"])
12+
def show(
13+
# optional parameter of organization name or id
14+
organization: str | None = None,
15+
) -> None:
16+
# get the client and call the list workspaces endpoint
17+
client = create_api_client()
18+
matched_organization: Organization
19+
if organization:
20+
matched_organization = client.get_organization(organization)
21+
if not matched_organization:
22+
print_info(f"Organization '{organization}' not found.")
23+
return
24+
else:
25+
user_organizations = client.list_organizations()
26+
if len(user_organizations) == 0:
27+
print_info("No organizations found.")
28+
return
29+
if len(user_organizations) > 1:
30+
print_error(
31+
"Multiple organizations found. Please specify an organization to list workspaces from."
32+
)
33+
return
34+
matched_organization = user_organizations[0]
35+
36+
workspace_filter = WorkspaceFilter(org_id=matched_organization.id)
37+
workspaces = client.list_workspaces(filters=workspace_filter)
38+
print_info(f"Workspaces in Organization '{matched_organization.name}':")
39+
for workspace in workspaces:
40+
print_info(f"- {workspace.name} (ID: {workspace.id})")
41+
print_info("")
42+
43+
44+
@cli.command(name=["create", "new"])
45+
def create(
46+
name: str,
47+
description: str | None = None,
48+
organization: str | None = None,
49+
) -> None:
50+
# get the client and call the create workspace endpoint
51+
client = create_api_client()
52+
if organization:
53+
matched_organization = client.get_organization(organization)
54+
if not matched_organization:
55+
print_info(f"Organization '{organization}' not found.")
56+
return
57+
else:
58+
user_organizations = client.list_organizations()
59+
if len(user_organizations) == 0:
60+
print_info("No organizations found. Please specify an organization.")
61+
return
62+
if len(user_organizations) > 1:
63+
print_error(
64+
"Multiple organizations found. Please specify an organization to create the workspace in."
65+
)
66+
return
67+
matched_organization = user_organizations[0]
68+
print_info(f"The workspace will be created in organization '{matched_organization.name}'")
69+
# verify with the user
70+
if not confirm("Do you want to continue?"):
71+
print_info("Workspace creation cancelled.")
72+
return
73+
74+
workspace: Workspace = client.create_workspace(
75+
name=name, organization_id=matched_organization.id, description=description
76+
)
77+
print_info(f"Workspace '{workspace.name}' created inwith ID: {workspace.id}")
78+
79+
80+
@cli.command(name=["delete", "rm"])
81+
def delete(
82+
workspace: str,
83+
organization: str | None = None,
84+
) -> None:
85+
# get the client and call the delete workspace endpoint
86+
client = create_api_client()
87+
if organization:
88+
matched_organization = client.get_organization(organization)
89+
if not matched_organization:
90+
print_info(f"Organization '{organization}' not found.")
91+
return
92+
else:
93+
user_organizations = client.list_organizations()
94+
if len(user_organizations) == 0:
95+
print_info("No organizations found. Please specify an organization.")
96+
return
97+
if len(user_organizations) > 1:
98+
print_error(
99+
"Multiple organizations found. Please specify an organization to delete the workspace from."
100+
)
101+
return
102+
matched_organization = user_organizations[0]
103+
104+
matched_workspace = client.get_workspace(workspace, org_id=matched_organization.id)
105+
if not matched_workspace:
106+
print_info(f"Workspace '{workspace}' not found.")
107+
return
108+
109+
# verify with the user
110+
if not confirm(
111+
f"Do you want to delete workspace '{matched_workspace.name}' from organization '{matched_organization.name}'? This will remove all associated data and access for all users."
112+
):
113+
print_info("Workspace deletion cancelled.")
114+
return
115+
116+
client.delete_workspace(matched_workspace.id)
117+
print_info(f"Workspace '{matched_workspace.name}' deleted.")

0 commit comments

Comments
 (0)