Skip to content

Commit 28b59a6

Browse files
authored
feat: SDK updates to support platform RBAC (#213)
* 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
1 parent 4bc171a commit 28b59a6

File tree

8 files changed

+566
-28
lines changed

8 files changed

+566
-28
lines changed

dreadnode/api/client.py

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from datetime import datetime, timezone
77
from pathlib import Path
88
from urllib.parse import urlparse
9+
from uuid import UUID
910

1011
import httpx
1112
from loguru import logger
@@ -19,6 +20,8 @@
1920
ExportFormat,
2021
GithubTokenResponse,
2122
MetricAggregationType,
23+
Organization,
24+
PaginatedWorkspaces,
2225
Project,
2326
RawRun,
2427
RawTask,
@@ -34,6 +37,8 @@
3437
TraceTree,
3538
UserDataCredentials,
3639
UserResponse,
40+
Workspace,
41+
WorkspaceFilter,
3742
)
3843
from dreadnode.api.util import (
3944
convert_flat_tasks_to_tree,
@@ -276,16 +281,47 @@ def list_projects(self) -> list[Project]:
276281
response = self.request("GET", "/strikes/projects")
277282
return [Project(**project) for project in response.json()]
278283

279-
def get_project(self, project: str) -> Project:
284+
def get_project(self, project_identifier: str | UUID, workspace_id: UUID) -> Project:
280285
"""Retrieves details of a specific project.
281286
282287
Args:
283-
project (str): The project identifier.
288+
project (str | UUID): The project identifier. ID, name, or slug.
284289
285290
Returns:
286291
Project: The Project object.
287292
"""
288-
response = self.request("GET", f"/strikes/projects/{project!s}")
293+
response = self.request(
294+
"GET",
295+
f"/strikes/projects/{project_identifier!s}",
296+
params={"workspace_id": workspace_id},
297+
)
298+
return Project(**response.json())
299+
300+
def create_project(
301+
self,
302+
name: str | UUID | None = None,
303+
workspace_id: UUID | None = None,
304+
organization_id: UUID | None = None,
305+
) -> Project:
306+
"""Creates a new project.
307+
308+
Args:
309+
name (str | UUID | None): The name of the project. If None, a default name will be used.
310+
workspace (str | UUID | None): The workspace ID to create the project in. If None, the default workspace will be used.
311+
organization (str | UUID | None): The organization ID to create the project in. If None, the default organization will be used.
312+
313+
Returns:
314+
Project: The created Project object.
315+
"""
316+
payload: dict[str, t.Any] = {}
317+
if name is not None:
318+
payload["name"] = name
319+
if workspace_id is not None:
320+
payload["workspace_id"] = str(workspace_id)
321+
if organization_id is not None:
322+
payload["org_id"] = str(organization_id)
323+
324+
response = self.request("POST", "/strikes/projects", json_data=payload)
289325
return Project(**response.json())
290326

291327
def list_runs(self, project: str) -> list[RunSummary]:
@@ -757,3 +793,97 @@ def get_platform_templates(self, tag: str) -> bytes:
757793
response = self.request("GET", "/platform/templates/all", params=params)
758794
zip_content: bytes = response.content
759795
return zip_content
796+
797+
# RBAC
798+
def list_organizations(self) -> list[Organization]:
799+
"""
800+
Retrieves a list of organizations the user belongs to.
801+
802+
Returns:
803+
A list of organization names.
804+
"""
805+
response = self.request("GET", "/organizations")
806+
return [Organization(**org) for org in response.json()]
807+
808+
def get_organization(self, organization_id: str | UUID) -> Organization:
809+
"""
810+
Retrieves details of a specific organization.
811+
812+
Args:
813+
organization_id (str): The organization identifier.
814+
815+
Returns:
816+
Organization: The Organization object.
817+
"""
818+
response = self.request("GET", f"/organizations/{organization_id!s}")
819+
return Organization(**response.json())
820+
821+
def list_workspaces(self, filters: WorkspaceFilter | None = None) -> list[Workspace]:
822+
"""
823+
Retrieves a list of workspaces the user has access to.
824+
825+
Returns:
826+
A list of workspace names.
827+
"""
828+
response = self.request(
829+
"GET", "/workspaces", params=filters.model_dump() if filters else None
830+
)
831+
paginated_workspaces = PaginatedWorkspaces(**response.json())
832+
# handle the pagination
833+
all_workspaces: list[Workspace] = paginated_workspaces.workspaces.copy()
834+
while paginated_workspaces.has_next:
835+
response = self.request(
836+
"GET",
837+
"/workspaces",
838+
params={
839+
"page": paginated_workspaces.page + 1,
840+
"limit": paginated_workspaces.limit,
841+
**(filters.model_dump() if filters else {}),
842+
},
843+
)
844+
next_page = PaginatedWorkspaces(**response.json())
845+
all_workspaces.extend(next_page.workspaces)
846+
paginated_workspaces.page = next_page.page
847+
paginated_workspaces.has_next = next_page.has_next
848+
849+
return all_workspaces
850+
851+
def get_workspace(self, workspace_id: str | UUID, org_id: UUID | None = None) -> Workspace:
852+
"""
853+
Retrieves details of a specific workspace.
854+
855+
Args:
856+
workspace_id (str): The workspace identifier.
857+
858+
Returns:
859+
Workspace: The Workspace object.
860+
"""
861+
params: dict[str, str] = {}
862+
if org_id:
863+
params = {"org_id": str(org_id)}
864+
response = self.request("GET", f"/workspaces/{workspace_id!s}", params=params)
865+
return Workspace(**response.json())
866+
867+
def create_workspace(
868+
self,
869+
name: str,
870+
organization_id: UUID,
871+
) -> Workspace:
872+
"""
873+
Creates a new workspace.
874+
875+
Args:
876+
name (str): The name of the workspace.
877+
organization_id (str | UUID): The organization ID to create the workspace in.
878+
879+
Returns:
880+
Workspace: The created Workspace object.
881+
"""
882+
883+
payload = {
884+
"name": name,
885+
"org_id": str(organization_id),
886+
}
887+
888+
response = self.request("POST", "/workspaces", json_data=payload)
889+
return Workspace(**response.json())

dreadnode/api/models.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,8 @@ class Project(BaseModel):
431431
"""Name of the project."""
432432
description: str | None = Field(repr=False)
433433
"""Description of the project."""
434+
workspace_id: UUID | None
435+
"""Unique identifier for the workspace the project belongs to."""
434436
created_at: datetime
435437
"""Timestamp when the project was created."""
436438
updated_at: datetime
@@ -441,6 +443,77 @@ class Project(BaseModel):
441443
"""Last run associated with the project, if any."""
442444

443445

446+
class Workspace(BaseModel):
447+
id: UUID
448+
"""Unique identifier for the workspace."""
449+
name: str
450+
"""Name of the workspace."""
451+
slug: str
452+
"""URL-friendly slug for the workspace."""
453+
description: str | None
454+
"""Description of the workspace."""
455+
created_by: UUID
456+
"""Unique identifier for the user who created the workspace."""
457+
org_id: UUID
458+
"""Unique identifier for the organization the workspace belongs to."""
459+
org_name: str | None
460+
"""Name of the organization the workspace belongs to."""
461+
is_active: bool
462+
"""Is the workspace active?"""
463+
is_default: bool
464+
"""Is the workspace the default one?"""
465+
project_count: int | None
466+
"""Number of projects in the workspace."""
467+
created_at: datetime
468+
"""Creation timestamp."""
469+
updated_at: datetime
470+
"""Last update timestamp."""
471+
472+
473+
class WorkspaceFilter(BaseModel):
474+
"""Filter parameters for workspace listing"""
475+
476+
org_id: UUID | None = Field(None, description="Filter by organization ID")
477+
478+
479+
class PaginatedWorkspaces(BaseModel):
480+
workspaces: list[Workspace]
481+
"""List of workspaces in the current page."""
482+
total: int
483+
"""Total number of workspaces available."""
484+
page: int
485+
"""Current page number."""
486+
limit: int
487+
"""Number of workspaces per page."""
488+
total_pages: int
489+
"""Total number of pages available."""
490+
has_next: bool
491+
"""Is there a next page available?"""
492+
has_previous: bool
493+
"""Is there a previous page available?"""
494+
495+
496+
class Organization(BaseModel):
497+
id: UUID
498+
"""Unique identifier for the organization."""
499+
name: str
500+
"""Name of the organization."""
501+
slug: str
502+
"""URL-friendly slug for the organization."""
503+
description: str | None
504+
"""Description of the organization."""
505+
is_active: bool
506+
"""Is the organization active?"""
507+
allow_external_invites: bool
508+
"""Allow external invites to the organization?"""
509+
max_members: int
510+
"""Maximum number of members allowed in the organization."""
511+
created_at: datetime
512+
"""Creation timestamp."""
513+
updated_at: datetime
514+
"""Last update timestamp."""
515+
516+
444517
# Derived types
445518

446519

dreadnode/cli/shared.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ class DreadnodeConfig:
1414
"""Server URL"""
1515
token: str | None = None
1616
"""API token"""
17+
organization: str | None = None
18+
"""Organization name"""
19+
workspace: str | None = None
20+
"""Workspace name"""
1721
project: str | None = None
1822
"""Project name"""
1923
profile: str | None = None
@@ -35,6 +39,8 @@ def apply(self) -> None:
3539
server=self.server,
3640
token=self.token,
3741
profile=self.profile,
42+
organization=self.organization,
43+
workspace=self.workspace,
3844
project=self.project,
3945
console=self.console,
4046
)

dreadnode/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
22
import pathlib
33

4+
from dreadnode.version import VERSION
5+
46
#
57
# Defaults
68
#
@@ -29,6 +31,10 @@
2931
DEFAULT_DOCKER_REGISTRY_LOCAL_PORT = 5005
3032
# default docker registry image tag
3133
DEFAULT_DOCKER_REGISTRY_IMAGE_TAG = "registry"
34+
# default workspace name
35+
DEFAULT_WORKSPACE_NAME = "Personal Workspace"
36+
# default project name
37+
DEFAULT_PROJECT_NAME = "Default"
3238

3339
#
3440
# Environment Variable Names
@@ -39,6 +45,8 @@
3945
ENV_API_TOKEN = "DREADNODE_API_TOKEN" # noqa: S105 # nosec
4046
ENV_API_KEY = "DREADNODE_API_KEY" # pragma: allowlist secret (alternative to API_TOKEN)
4147
ENV_LOCAL_DIR = "DREADNODE_LOCAL_DIR"
48+
ENV_ORGANIZATION = "DREADNODE_ORGANIZATION"
49+
ENV_WORKSPACE = "DREADNODE_WORKSPACE"
4250
ENV_PROJECT = "DREADNODE_PROJECT"
4351
ENV_PROFILE = "DREADNODE_PROFILE"
4452
ENV_CONSOLE = "DREADNODE_CONSOLE"
@@ -61,3 +69,6 @@
6169

6270
# Default values for the file system credential management
6371
FS_CREDENTIAL_REFRESH_BUFFER = 900 # 15 minutes in seconds
72+
73+
# Default User-Agent
74+
DEFAULT_USER_AGENT = f"dreadnode/{VERSION}"

dreadnode/exporter.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import typing as t
2+
3+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
4+
5+
from dreadnode.constants import DEFAULT_USER_AGENT
6+
7+
8+
class CustomOTLPSpanExporter(OTLPSpanExporter):
9+
"""A custom OTLP exporter that injects our SDK version into the User-Agent."""
10+
11+
def __init__(self, **kwargs: t.Any) -> None:
12+
super().__init__(**kwargs)
13+
14+
# 2. Get the current User-Agent set by OTel (e.g., OTel-OTLP-Exporter-Python/<version>)
15+
otlp_user_agent = self._session.headers.get("User-Agent")
16+
if isinstance(otlp_user_agent, bytes):
17+
otlp_user_agent = otlp_user_agent.decode("utf-8")
18+
19+
# 3. Combine the User-Agent strings.
20+
if otlp_user_agent:
21+
combined_user_agent = f"{DEFAULT_USER_AGENT} {otlp_user_agent}"
22+
self._session.headers["User-Agent"] = combined_user_agent
23+
else:
24+
# Fallback if somehow OTel didn't set a User-Agent
25+
self._session.headers["User-Agent"] = DEFAULT_USER_AGENT

0 commit comments

Comments
 (0)