From 326905c4234eb79e32e79c84efc212ae6bbf1077 Mon Sep 17 00:00:00 2001 From: Ryan S <267728323+ironcommit@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:14:42 -0700 Subject: [PATCH] feat(auth): govern workspace creation through rbac Signed-off-by: Ryan S <267728323+ironcommit@users.noreply.github.com> --- docs/auth/authentication/oidc.mdx | 2 +- .../authorization/permissions-reference.mdx | 1 + .../authorization/roles-and-permissions.mdx | 4 +- docs/auth/concepts.mdx | 6 +- docs/auth/deployment/configuration.mdx | 2 + docs/get-started/concepts/workspaces.mdx | 34 ++++++ docs/get-started/setup.mdx | 2 +- services/core/auth/scripts/auth-tools.py | 8 +- .../src/nmp/core/auth/app/policies/authz.rego | 12 +-- .../policy_tests/namespace_access_test.rego | 17 +-- .../policy_tests/namespace_creation_test.rego | 56 ++++++---- .../policy_tests/unknown_endpoint_test.rego | 44 ++++++-- .../auth/src/nmp/core/auth/app/seeding.py | 29 ++++- .../nmp/core/auth/assets/static-authz.yaml | 13 ++- services/core/auth/tests/test_embedded_pdp.py | 56 +++++++++- services/core/auth/tests/test_seeding.py | 100 +++++++++++++++++- .../test_workspaces_crud_with_auth.py | 91 ++++++++++++++++ services/platform-seed/README.md | 4 +- 18 files changed, 412 insertions(+), 69 deletions(-) diff --git a/docs/auth/authentication/oidc.mdx b/docs/auth/authentication/oidc.mdx index 4ccecf51d6..99f6f131a2 100644 --- a/docs/auth/authentication/oidc.mdx +++ b/docs/auth/authentication/oidc.mdx @@ -71,7 +71,7 @@ curl -sf http://localhost:8080/health/ready uv run python -m nmp.platform_seed ``` -The seed task creates the **PlatformAdmin** binding for `auth.admin_email`, plus the default wildcard bindings used for authenticated access. Without this step, OIDC login can succeed while admin operations return `403 Forbidden`. +The seed task creates the **PlatformAdmin** binding for `auth.admin_email`, the wildcard **Editor** binding for the `default` workspace, the wildcard **Viewer** binding for the `system` workspace, and the wildcard **WorkspaceCreator** binding for `system` that preserves open workspace creation by default. Without this step, OIDC login can succeed while admin operations, workspace creation, and access to the default and system workspaces can still return `403 Forbidden`. ### Step 4: Configure Scopes (Optional) diff --git a/docs/auth/authorization/permissions-reference.mdx b/docs/auth/authorization/permissions-reference.mdx index d3144f67cd..9e2d6abc6f 100644 --- a/docs/auth/authorization/permissions-reference.mdx +++ b/docs/auth/authorization/permissions-reference.mdx @@ -112,6 +112,7 @@ PlatformAdmin is omitted — it bypasses permission checks entirely at the polic |------------|-------------|:------:|:------:|:-----:| | workspaces.(read | list) | Read, list workspaces | ✓ | ✓ | ✓ | | workspaces.(update | delete) | Update, delete workspaces | | ✓ | ✓ | +| `workspaces.create` | Create workspaces | | | | | workspaces.members.(list | create | update | delete) | List, create, update, delete workspaces members | | | ✓ | | `workspaces.members.read` | Read workspace member details | | | | diff --git a/docs/auth/authorization/roles-and-permissions.mdx b/docs/auth/authorization/roles-and-permissions.mdx index 20c0ceffae..14ee5acc3c 100644 --- a/docs/auth/authorization/roles-and-permissions.mdx +++ b/docs/auth/authorization/roles-and-permissions.mdx @@ -73,7 +73,6 @@ Rows are operations; columns are roles. Read the hierarchy above first: each rol | Operation | Viewer | Editor | Admin | PlatformAdmin | |-----------|:------:|:------:|:-----:|:-------------:| | List workspaces (visible to user) | ✓ | ✓ | ✓ | ✓ | -| Create workspace | ✓ | ✓ | ✓ | ✓ | | Delete workspace | | | ✓ | ✓ | | List workspace members | ✓ | ✓ | ✓ | ✓ | | Add / remove members | | | ✓ | ✓ | @@ -81,7 +80,7 @@ Rows are operations; columns are roles. Read the hierarchy above first: each rol -All authenticated users can create workspaces. The creator automatically becomes Admin. +Workspace creation is controlled by the `WorkspaceCreator` permission in the `system` workspace rather than by the workspace roles in the table above. By default, platform seed grants `WorkspaceCreator` to the wildcard principal `*`, so all authenticated users can still create workspaces until operators change that binding. The creator automatically becomes Admin. ### Resource Operations (Models, Datasets, Projects) @@ -138,6 +137,7 @@ NeMo Platform automatically provisions wildcard bindings on built-in workspaces: |-----------|--------------|--------| | `default` | Editor for `*` | All authenticated users can create and manage resources | | `system` | Viewer for `*` | All authenticated users have read-only access to system resources | +| `system` | WorkspaceCreator for `*` | All authenticated users can create workspaces until operators rebind the role | ## Admin Protection diff --git a/docs/auth/concepts.mdx b/docs/auth/concepts.mdx index ecd4f7b70e..e653cce7f6 100644 --- a/docs/auth/concepts.mdx +++ b/docs/auth/concepts.mdx @@ -145,9 +145,10 @@ flowchart TB ### Workspace Creation -Creating a workspace is a special operation: +Creating a workspace is a system-scoped RBAC operation: -- All authenticated users can create workspaces (no special permission needed) +- By default, all authenticated users can create workspaces because platform seed grants `WorkspaceCreator` to the wildcard principal `*` in the `system` workspace +- Operators can restrict workspace creation by removing that wildcard binding and granting `WorkspaceCreator` only to specific users or groups - The creator automatically receives the **Admin** role - New workspaces are private by default (only the creator has access) - You can immediately start creating resources @@ -158,6 +159,7 @@ NeMo Platform automatically provisions role bindings for the wildcard principal - **`default`**: All authenticated users have **Editor** role, allowing everyone to create and manage resources immediately - **`system`**: All authenticated users have **Viewer** role, providing read-only access to system-level resources +- **`system`**: All authenticated users also receive the seeded **WorkspaceCreator** role, preserving open workspace creation until operators rebind it ## Workspace Visibility diff --git a/docs/auth/deployment/configuration.mdx b/docs/auth/deployment/configuration.mdx index 6f34ef47e7..19373f94c6 100644 --- a/docs/auth/deployment/configuration.mdx +++ b/docs/auth/deployment/configuration.mdx @@ -39,6 +39,8 @@ auth: The binding is created by `platform-seed`, not by auth service startup alone. Helm deployments run the platform-seed Job by default. For source installs, set `platform.seed_on_startup: true`, export `NMP_SEED_ON_STARTUP=true` before `nemo services run`, or run `uv run python -m nmp.platform_seed` after services are healthy. If platform seed has not run, `admin_email` users can authenticate but still receive `403 Forbidden` because their PlatformAdmin binding does not exist. +Platform seed also creates the default wildcard workspace bindings: `Editor` on the default workspace, `Viewer` on `system`, and `WorkspaceCreator` on `system`. That last binding is what preserves open workspace creation by default. Remove or replace it if you want workspace creation restricted to designated users or groups. + This page covers the auth-specific configuration fields you need to enable and operate authorization. Auth-related values are found under `platformConfig.auth` in the values file. For OIDC-specific fields (`auth.oidc`), see [OIDC Setup](/documentation/access-control/authentication/oidc-setup). diff --git a/docs/get-started/concepts/workspaces.mdx b/docs/get-started/concepts/workspaces.mdx index d205f33bce..8ef0152661 100644 --- a/docs/get-started/concepts/workspaces.mdx +++ b/docs/get-started/concepts/workspaces.mdx @@ -26,6 +26,10 @@ Once created, a workspace cannot be renamed—choose the `name` carefully. The ` The system also generates a unique `id` (UUID) for internal use, but the `name` is the primary identifier used in API paths and SDK calls. +By default, any authenticated user can create a workspace because NeMo seeds a wildcard `WorkspaceCreator` role binding in the `system` workspace. The creator still automatically becomes `Admin` of the new workspace. + +To restrict workspace creation, remove that wildcard `WorkspaceCreator` binding and grant the role only to the users or groups that should retain create access. This changes creation policy without changing the API surface. + @@ -56,6 +60,36 @@ nemo workspaces create ml-team \ +## Restrict Workspace Creation + +Workspace creation is now governed by RBAC instead of a hardcoded policy exception. The default seed creates this binding: + +```text +principal: * +workspace: system +role: WorkspaceCreator +``` + +To restrict workspace creation: + +1. Remove the wildcard `WorkspaceCreator` binding from `system`. +2. Grant `WorkspaceCreator` only to the users or groups that should create workspaces. + +Example: + +```bash +nemo workspaces members update "*" \ + --workspace system \ + --roles Viewer + +nemo workspaces members create \ + --workspace system \ + --principal ml-leads@bigcorp.com \ + --roles WorkspaceCreator +``` + +Updating the wildcard member to `Viewer` preserves the seeded read-only access on `system` while revoking `WorkspaceCreator`. + ## List Workspaces To list workspaces, call the list endpoint. When authentication is enabled, only workspaces the user has access to are returned. The response includes pagination metadata. diff --git a/docs/get-started/setup.mdx b/docs/get-started/setup.mdx index cea307ebad..8e85300644 100644 --- a/docs/get-started/setup.mdx +++ b/docs/get-started/setup.mdx @@ -95,7 +95,7 @@ curl -sf http://localhost:8080/health/ready uv run python -m nmp.platform_seed ``` -`uv run nemo-platform run task --task nmp.platform_seed` is equivalent. The seed task is idempotent. It creates the **PlatformAdmin** binding in the `system` workspace for `auth.admin_email`, the wildcard **Editor** binding for the default workspace, and the wildcard **Viewer** binding for the `system` workspace. Without this step, OIDC login can succeed but admin operations may still return `403 Forbidden` because the RBAC bindings are missing. +`uv run nemo-platform run task --task nmp.platform_seed` is equivalent. The seed task is idempotent. It creates the **PlatformAdmin** binding in the `system` workspace for `auth.admin_email`, the wildcard **Editor** binding for the default workspace, the wildcard **Viewer** binding for the `system` workspace, and the wildcard **WorkspaceCreator** binding for the `system` workspace. Without this step, OIDC login can succeed but admin operations may still return `403 Forbidden` because the RBAC bindings are missing. When setup completes, try chatting with the model you selected. Use the model name shown at the end of setup (or find it with `nemo models list`): diff --git a/services/core/auth/scripts/auth-tools.py b/services/core/auth/scripts/auth-tools.py index cdea538b86..7d829c1825 100755 --- a/services/core/auth/scripts/auth-tools.py +++ b/services/core/auth/scripts/auth-tools.py @@ -322,9 +322,9 @@ def infer_permissions(path: str, method: str) -> List[str]: elif "embeddings" in path: return ["inference.embeddings"] - # Special case for workspace creation (empty permissions — open to authenticated users) + # Special case for workspace creation (system-scoped RBAC, not workspace-scoped RBAC) if path.endswith("/workspaces") and method == "post": - return [] + return ["workspaces.create"] # Special case for workspace members (permission lives under workspaces.* regardless # of which API area exposes the route, e.g. /apis/entities/v2/workspaces/{ws}/members) @@ -383,9 +383,9 @@ def infer_scopes(path: str, method: str) -> List[str]: Every endpoint gets both an area-specific scope (e.g. models:read) and the corresponding platform catch-all scope (platform:read/platform:write). """ - # Special case for workspace creation (empty scopes - open to all authenticated users) + # Special case for workspace creation (normal write scopes despite no workspace in path) if path.endswith("/workspaces") and method == "post": - return [] + return ["entities:write", "platform:write"] # Determine read/write based on method is_write = method in ["post", "put", "patch", "delete"] diff --git a/services/core/auth/src/nmp/core/auth/app/policies/authz.rego b/services/core/auth/src/nmp/core/auth/app/policies/authz.rego index a869bf04df..ce9fdc1338 100644 --- a/services/core/auth/src/nmp/core/auth/app/policies/authz.rego +++ b/services/core/auth/src/nmp/core/auth/app/policies/authz.rego @@ -110,16 +110,14 @@ allow_request if { has_permissions(principal, workspace, required_permissions) } -# IAM APIs under /apis/auth/v2/iam/ — patterns have no {workspace} placeholder, so -# extract_workspace_from_path is undefined and workspace-scoped rules do not apply. -# Check permissions against the system workspace (PlatformAdmin, ServiceSystem *, etc.). +# Workspace-less endpoints with required permissions: check against the system workspace. +# Covers IAM APIs, workspace creation, and any future workspace-less permission-gated +# endpoint without needing path-specific rules. allow_request if { applicable_principals := get_applicable_principals count(applicable_principals) > 0 scope_check_passed path := extract_path - base_path := split(path, "?")[0] - startswith(base_path, "/apis/auth/v2/iam/") method := extract_method required_permissions := req_permissions count(required_permissions) > 0 @@ -204,12 +202,12 @@ allow_request if { workspace == "-" } -# Allow if endpoint explicitly has no required permissions (e.g., workspace creation) +# Allow if endpoint explicitly has no required permissions # but still require authentication (at least one principal). # # SECURITY: We check the endpoint config directly instead of using get_required_permissions, # because we need to distinguish between: -# - endpoints explicitly configured with `permissions: []` → allow (e.g., workspace creation) +# - endpoints explicitly configured with `permissions: []` → allow # - endpoints not in the config at all (unknown) → deny (fail-closed) # If normalize_endpoint cannot match the path, it is undefined, the rule body fails, # and access is denied. diff --git a/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_access_test.rego b/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_access_test.rego index 36eb20fc5e..bbb86f0159 100644 --- a/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_access_test.rego +++ b/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_access_test.rego @@ -11,7 +11,10 @@ workspace_access_test_data := { }, "Editor": { "includes": ["Viewer"], - "permissions": ["workspaces.update", "workspaces.create"] + "permissions": ["workspaces.update"] + }, + "WorkspaceCreator": { + "permissions": ["workspaces.create"] }, "Admin": { "includes": ["Editor"], @@ -36,8 +39,8 @@ workspace_access_test_data := { "scopes": ["platform:read"] }, "post": { - "permissions": [], - "scopes": [] + "permissions": ["workspaces.create"], + "scopes": ["entities:write", "platform:write"] } }, "/apis/entities/v2/workspaces/{name}": { @@ -101,19 +104,21 @@ test_list_workspaces_without_system_permission_denied if { # ============================================================================ test_create_workspace_allowed if { - # Any authenticated user should be allowed to create a workspace + # Workspace creation is authorized through a system-scoped role binding. result := authz.allow with input as { "principal_id": "user123", "principal_email": "creator@example.com", "method": "POST", "path": "/apis/entities/v2/workspaces", - "scopes": [] + "scopes": ["entities:write", "platform:write"] } with data.authz.roles as workspace_access_test_data.roles with data.authz.endpoints as workspace_access_test_data.endpoints with data.authz.workspaces as {} # Empty initially - with data.authz.principals as {} + with data.authz.principals as { + "*": {"workspaces": {"system": ["WorkspaceCreator"]}} + } result.allowed == true } diff --git a/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_creation_test.rego b/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_creation_test.rego index 96af137a44..4ad90c1610 100644 --- a/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_creation_test.rego +++ b/services/core/auth/src/nmp/core/auth/app/policy_tests/namespace_creation_test.rego @@ -9,6 +9,9 @@ workspace_test_data := { "Viewer": { "permissions": ["workspaces.read", "workspaces.list"] }, + "WorkspaceCreator": { + "permissions": ["workspaces.create"] + }, "Editor": { "includes": ["Viewer"], "permissions": ["workspaces.update"] @@ -21,7 +24,10 @@ workspace_test_data := { "endpoints": { "/apis/entities/v2/workspaces": { "get": {"permissions": ["workspaces.list"]}, - "post": {"permissions": []} # Empty permissions - any authenticated user can create + "post": { + "permissions": ["workspaces.create"], + "scopes": ["entities:write", "platform:write"] + } }, "/apis/entities/v2/workspaces/{name}": { "get": {"permissions": ["workspaces.read"]}, @@ -33,23 +39,27 @@ workspace_test_data := { "existing-ns": {} }, "principals": { - "new-user@test.com": { - "workspaces": {} # User has no workspace permissions yet + "*": { + "workspaces": {"system": ["WorkspaceCreator"]} }, "existing-user@test.com": { # Viewer of existing-ns plus a system Viewer grant — listing workspaces now requires # workspaces.list in the system workspace. "workspaces": {"existing-ns": ["Viewer"], "system": ["Viewer"]} + }, + "group:ml-leads": { + "workspaces": {"system": ["WorkspaceCreator"]} } } } -# Test that any authenticated user can create a workspace -test_authenticated_user_can_create_workspace if { +# Test that wildcard WorkspaceCreator preserves current default behavior +test_workspace_creation_allowed_via_wildcard_binding if { result := authz.allow with input as { - "principal_id": "new-user@test.com", + "principal_id": "plain@test.com", "method": "POST", - "path": "/apis/entities/v2/workspaces" + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"] } with data.authz.roles as workspace_test_data.roles with data.authz.endpoints as workspace_test_data.endpoints @@ -59,19 +69,20 @@ test_authenticated_user_can_create_workspace if { result.allowed == true } -# Test that authenticated user without any permissions can still create workspace -test_user_without_permissions_can_create_workspace if { +# Test that a plain user is denied once the wildcard binding is removed +test_workspace_creation_denied_without_creator_binding if { result := authz.allow with input as { - "principal_id": "random-user@test.com", # Not even in principals list + "principal_id": "plain@test.com", "method": "POST", - "path": "/apis/entities/v2/workspaces" + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"] } with data.authz.roles as workspace_test_data.roles with data.authz.endpoints as workspace_test_data.endpoints with data.authz.workspaces as workspace_test_data.workspaces - with data.authz.principals as workspace_test_data.principals + with data.authz.principals as {} - result.allowed == true + result.allowed == false } # Test that unauthenticated users cannot create workspaces @@ -79,7 +90,8 @@ test_unauthenticated_user_cannot_create_workspace if { result := authz.allow with input as { "principal_id": "", # Empty principal ID "method": "POST", - "path": "/apis/entities/v2/workspaces" + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"] } with data.authz.roles as workspace_test_data.roles with data.authz.endpoints as workspace_test_data.endpoints @@ -121,12 +133,14 @@ test_listing_workspaces_without_permission_denied if { result.allowed == false } -# Test allow for workspace creation -test_allow_workspace_creation if { +# Test workspace creation allowed via group binding in system workspace +test_workspace_creation_allowed_via_group_binding if { result := authz.allow with input as { - "principal_id": "new-user@test.com", + "principal_id": "lead@test.com", + "principal_groups": ["group:ml-leads"], "method": "POST", - "path": "/apis/entities/v2/workspaces" + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"] } with data.authz.roles as workspace_test_data.roles with data.authz.endpoints as workspace_test_data.endpoints @@ -136,9 +150,9 @@ test_allow_workspace_creation if { result.allowed == true } -# Test that get_required_permissions returns empty array for workspace creation -test_workspace_creation_has_no_required_permissions if { +# Test that get_required_permissions returns workspaces.create for workspace creation +test_workspace_creation_requires_permission if { perms := common.get_required_permissions("/apis/entities/v2/workspaces", "POST") with data.authz.endpoints as workspace_test_data.endpoints - perms == [] + perms == ["workspaces.create"] } diff --git a/services/core/auth/src/nmp/core/auth/app/policy_tests/unknown_endpoint_test.rego b/services/core/auth/src/nmp/core/auth/app/policy_tests/unknown_endpoint_test.rego index 30aad25658..761f4c2fe4 100644 --- a/services/core/auth/src/nmp/core/auth/app/policy_tests/unknown_endpoint_test.rego +++ b/services/core/auth/src/nmp/core/auth/app/policy_tests/unknown_endpoint_test.rego @@ -19,11 +19,17 @@ unknown_endpoint_test_data := { "includes": ["Viewer"], "permissions": ["entities.create", "entities.update", "workspaces.update"], }, + "WorkspaceCreator": { + "permissions": ["workspaces.create"], + }, }, "endpoints": { "/apis/entities/v2/workspaces": { "get": {"permissions": ["workspaces.list"]}, - "post": {"permissions": []}, + "post": { + "permissions": ["workspaces.create"], + "scopes": ["entities:write", "platform:write"], + }, }, "/apis/entities/v2/workspaces/{name}": { "get": {"permissions": ["workspaces.read"]}, @@ -136,24 +142,44 @@ test_unknown_endpoint_denied_cross_workspace_list_head if { result.allowed == false } -# --- Existing behavior preserved: explicit permissions: [] still works --- +# --- Existing behavior preserved: configured endpoints still work --- -# Endpoints explicitly configured with permissions: [] (like workspace creation) -# must still allow any authenticated user. -test_explicit_empty_permissions_still_allowed if { +# Workspace creation still works when the caller has a system-scoped creator binding. +test_workspace_creation_allowed_with_creator_binding if { result := authz.allow with input as { "principal_id": "user@test.com", "method": "POST", "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"], } with data.authz.roles as unknown_endpoint_test_data.roles with data.authz.endpoints as unknown_endpoint_test_data.endpoints with data.authz.workspaces as unknown_endpoint_test_data.workspaces - with data.authz.principals as unknown_endpoint_test_data.principals + with data.authz.principals as { + "*": {"workspaces": {"system": ["WorkspaceCreator"]}}, + } result.allowed == true } +# Workspace creation must still enforce the endpoint's write scopes. +test_workspace_creation_denied_with_wrong_scope if { + result := authz.allow with input as { + "principal_id": "user@test.com", + "method": "POST", + "path": "/apis/entities/v2/workspaces", + "scopes": ["platform:read"], + } + with data.authz.roles as unknown_endpoint_test_data.roles + with data.authz.endpoints as unknown_endpoint_test_data.endpoints + with data.authz.workspaces as unknown_endpoint_test_data.workspaces + with data.authz.principals as { + "*": {"workspaces": {"system": ["WorkspaceCreator"]}}, + } + + result.allowed == false +} + # --- Bypass rules are unaffected --- # Service principals can still access any endpoint, including unknown ones. @@ -285,9 +311,9 @@ test_get_required_permissions_undefined_for_unknown if { with data.authz.endpoints as unknown_endpoint_test_data.endpoints } -# get_required_permissions still returns [] for endpoints with explicit empty permissions. -test_get_required_permissions_empty_for_explicit if { +# get_required_permissions still returns the configured permission for known endpoints. +test_get_required_permissions_for_workspace_creation if { perms := common.get_required_permissions("/apis/entities/v2/workspaces", "POST") with data.authz.endpoints as unknown_endpoint_test_data.endpoints - perms == [] + perms == ["workspaces.create"] } diff --git a/services/core/auth/src/nmp/core/auth/app/seeding.py b/services/core/auth/src/nmp/core/auth/app/seeding.py index c8262e9444..f0a858c031 100644 --- a/services/core/auth/src/nmp/core/auth/app/seeding.py +++ b/services/core/auth/src/nmp/core/auth/app/seeding.py @@ -36,6 +36,7 @@ WILDCARD_PRINCIPAL = "*" DEFAULT_WORKSPACE_ROLE = "Editor" SYSTEM_WORKSPACE_ROLE = "Viewer" +WORKSPACE_CREATOR_ROLE = "WorkspaceCreator" def _generate_binding_name(principal: str, workspace: str, role: str) -> str: @@ -183,7 +184,8 @@ async def _seed_wildcard_binding( description: Human-readable description for logging Returns: - True if binding exists (created or already present), False if creation failed. + True if binding exists, was intentionally revoked, or was created successfully. + False only if creation fails unexpectedly. """ binding_name = _generate_binding_name(WILDCARD_PRINCIPAL, workspace, role) @@ -201,11 +203,11 @@ async def _seed_wildcard_binding( ) return True else: - logger.warning( - f"Wildcard {description} binding exists but is revoked", + logger.info( + f"Wildcard {description} binding is revoked; preserving operator override", extra={"workspace": workspace, "role": role}, ) - return False + return True except EntityNotFoundError: pass @@ -284,6 +286,21 @@ async def seed_system_workspace_viewer(entity_client: EntityClient) -> bool: ) +async def seed_workspace_creator(entity_client: EntityClient) -> bool: + """Seed WorkspaceCreator role binding for wildcard principal on system workspace. + + This preserves the current product behavior: any authenticated user can create + workspaces by default. Operators can later revoke or replace this binding to + restrict creation to specific users or groups. + """ + return await _seed_wildcard_binding( + entity_client, + workspace=SYSTEM_WORKSPACE, + role=WORKSPACE_CREATOR_ROLE, + description="system workspace WorkspaceCreator", + ) + + async def run_seeding(entity_client: EntityClient) -> bool: """Run all seeding operations. @@ -314,5 +331,9 @@ async def run_seeding(entity_client: EntityClient) -> bool: logger.error("Failed to seed system workspace Viewer for wildcard principal") return False + if not await seed_workspace_creator(entity_client): + logger.error("Failed to seed system workspace WorkspaceCreator for wildcard principal") + return False + logger.debug("Auth service seeding complete") return True diff --git a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml index 80a010705f..ec6a9f7dfd 100644 --- a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml +++ b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml @@ -255,6 +255,8 @@ authz: update: description: "Update secrets" workspaces: + create: + description: "Create workspaces" delete: description: "Delete workspaces" list: @@ -389,6 +391,10 @@ authz: - workspaces.members.delete - models.trust-remote-code.set - models.tool-call-plugin.set + WorkspaceCreator: + description: "Create new workspaces" + permissions: + - workspaces.create PlatformAdmin: description: "Platform-wide administrative access" permissions: @@ -461,8 +467,11 @@ authz: - entities:read - platform:read post: - permissions: [] - scopes: [] + permissions: + - workspaces.create + scopes: + - entities:write + - platform:write /apis/entities/v2/workspaces/{name}: delete: permissions: diff --git a/services/core/auth/tests/test_embedded_pdp.py b/services/core/auth/tests/test_embedded_pdp.py index 6f6f86070a..fc82470f82 100644 --- a/services/core/auth/tests/test_embedded_pdp.py +++ b/services/core/auth/tests/test_embedded_pdp.py @@ -673,6 +673,54 @@ def test_viewer_can_still_read_workspace(self, static_authz_data): ) assert result["allowed"] is True + def test_workspace_create_uses_system_scoped_permission(self, static_authz_data): + static_authz_data["authz"]["principals"] = { + "*": {"workspaces": {"system": ["WorkspaceCreator"]}}, + "admin@test.com": {"workspaces": {"system": ["PlatformAdmin"]}}, + "group:ml-leads": {"workspaces": {"system": ["WorkspaceCreator"]}}, + } + set_policy_data(static_authz_data) + + wildcard_allowed = evaluate( + "allow", + { + "principal_id": "plain-user@test.com", + "method": "POST", + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"], + }, + ) + assert wildcard_allowed["allowed"] is True + + static_authz_data["authz"]["principals"] = { + "admin@test.com": {"workspaces": {"system": ["PlatformAdmin"]}}, + "group:ml-leads": {"workspaces": {"system": ["WorkspaceCreator"]}}, + } + set_policy_data(static_authz_data) + + plain_denied = evaluate( + "allow", + { + "principal_id": "plain-user@test.com", + "method": "POST", + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"], + }, + ) + group_allowed = evaluate( + "allow", + { + "principal_id": "lead-user@test.com", + "principal_groups": ["group:ml-leads"], + "method": "POST", + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"], + }, + ) + + assert plain_denied["allowed"] is False + assert group_allowed["allowed"] is True + class TestPerAreaScopes: """Validate that every endpoint in static-authz.yaml has per-area scopes. @@ -801,12 +849,12 @@ def test_scope_read_write_consistency(self, static_authz_data): assert not inconsistent, "Inconsistent read/write scopes:\n" + "\n".join(inconsistent) - def test_workspace_creation_has_empty_scopes(self, static_authz_data): - """POST /apis/entities/v2/workspaces should have empty scopes (open to all authenticated users).""" + def test_workspace_creation_requires_write_scopes_and_permission(self, static_authz_data): + """POST /apis/entities/v2/workspaces should participate in normal write-scope RBAC.""" ws_endpoint = static_authz_data["authz"]["endpoints"].get("/apis/entities/v2/workspaces", {}) post_config = ws_endpoint.get("post", {}) - assert post_config.get("scopes") == [], "Workspace creation (POST) should have empty scopes" - assert post_config.get("permissions") == [], "Workspace creation (POST) should have empty permissions" + assert post_config.get("scopes") == ["entities:write", "platform:write"] + assert post_config.get("permissions") == ["workspaces.create"] def test_audit_workspace_endpoints_have_audit_scopes(self, static_authz_data): """All workspace-scoped audit endpoints should have audit:read/audit:write scopes.""" diff --git a/services/core/auth/tests/test_seeding.py b/services/core/auth/tests/test_seeding.py index ac6a466d5f..0fe1f86596 100644 --- a/services/core/auth/tests/test_seeding.py +++ b/services/core/auth/tests/test_seeding.py @@ -13,11 +13,13 @@ PLATFORM_ADMIN_ROLE, SYSTEM_WORKSPACE_ROLE, WILDCARD_PRINCIPAL, + WORKSPACE_CREATOR_ROLE, _generate_binding_name, run_seeding, seed_default_workspace_editor, seed_platform_admin, seed_system_workspace_viewer, + seed_workspace_creator, ) from nmp.core.auth.entities import RoleBindingEntity @@ -207,6 +209,21 @@ async def test_seed_uses_configured_workspace(self, mock_entity_client): created_entity = mock_entity_client.create.call_args[0][0] assert created_entity.workspace == "my-custom-workspace" + @pytest.mark.asyncio + async def test_seed_treats_revoked_binding_as_intentional_override(self, mock_config): + """Test that a revoked wildcard binding is not recreated or treated as failure.""" + mock_entity_client = MagicMock() + existing_binding = MagicMock() + existing_binding.revoked_at = datetime.now(timezone.utc) + mock_entity_client.get = AsyncMock(return_value=existing_binding) + mock_entity_client.create = AsyncMock() + + with patch("nmp.core.auth.app.seeding.get_service_config", return_value=mock_config): + result = await seed_default_workspace_editor(mock_entity_client) + + assert result is True + mock_entity_client.create.assert_not_called() + class TestSeedSystemWorkspaceViewer: """Tests for system workspace Viewer seeding.""" @@ -259,6 +276,62 @@ async def test_seed_handles_concurrent_creation(self, mock_entity_client): assert result is True +class TestSeedWorkspaceCreator: + """Tests for system workspace WorkspaceCreator seeding.""" + + @pytest.fixture + def mock_entity_client(self): + """Create a mock entity client with get raising NotFound (new binding case).""" + client = MagicMock() + client.get = AsyncMock(side_effect=EntityNotFoundError("Not found")) + client.create = AsyncMock() + return client + + @pytest.mark.asyncio + async def test_seed_creates_wildcard_workspace_creator_binding(self, mock_entity_client): + """Test that seeding creates the wildcard WorkspaceCreator binding.""" + result = await seed_workspace_creator(mock_entity_client) + + assert result is True + mock_entity_client.create.assert_called_once() + + created_entity = mock_entity_client.create.call_args[0][0] + assert isinstance(created_entity, RoleBindingEntity) + assert created_entity.workspace == SYSTEM_WORKSPACE + assert created_entity.principal == WILDCARD_PRINCIPAL + assert created_entity.role == WORKSPACE_CREATOR_ROLE + assert created_entity.name == "wildcard-system-workspacecreator" + assert created_entity.granted_by == "system" + + @pytest.mark.asyncio + async def test_seed_handles_existing_binding(self): + """Test that seeding returns True when binding already exists.""" + mock_entity_client = MagicMock() + existing_binding = MagicMock() + existing_binding.revoked_at = None + mock_entity_client.get = AsyncMock(return_value=existing_binding) + mock_entity_client.create = AsyncMock() + + result = await seed_workspace_creator(mock_entity_client) + + assert result is True + mock_entity_client.create.assert_not_called() + + @pytest.mark.asyncio + async def test_seed_treats_revoked_binding_as_intentional_override(self): + """Test that a revoked WorkspaceCreator wildcard binding is preserved.""" + mock_entity_client = MagicMock() + existing_binding = MagicMock() + existing_binding.revoked_at = datetime.now(timezone.utc) + mock_entity_client.get = AsyncMock(return_value=existing_binding) + mock_entity_client.create = AsyncMock() + + result = await seed_workspace_creator(mock_entity_client) + + assert result is True + mock_entity_client.create.assert_not_called() + + class TestRunSeeding: """Tests for the main seeding entry point.""" @@ -293,8 +366,8 @@ async def test_run_seeding_returns_true_on_success(self, mock_entity_client, moc result = await run_seeding(mock_entity_client) assert result is True - # Should create: platform admin + default workspace editor + system workspace viewer - assert mock_entity_client.create.call_count == 3 + # Should create: platform admin + default workspace editor + system workspace viewer + workspace creator + assert mock_entity_client.create.call_count == 4 @pytest.mark.asyncio async def test_run_seeding_seeds_wildcard_bindings_without_admin(self, mock_entity_client, mock_config_no_admin): @@ -303,8 +376,8 @@ async def test_run_seeding_seeds_wildcard_bindings_without_admin(self, mock_enti result = await run_seeding(mock_entity_client) assert result is True - # Should create: default workspace editor + system workspace viewer (no platform admin) - assert mock_entity_client.create.call_count == 2 + # Should create: default workspace editor + system workspace viewer + workspace creator (no platform admin) + assert mock_entity_client.create.call_count == 3 @pytest.mark.asyncio async def test_run_seeding_returns_false_on_platform_admin_failure(self, mock_config_with_admin): @@ -329,3 +402,22 @@ async def test_run_seeding_returns_false_on_wildcard_failure(self, mock_config_n result = await run_seeding(mock_entity_client) assert result is False + + @pytest.mark.asyncio + async def test_run_seeding_succeeds_when_workspace_creator_binding_was_revoked(self, mock_config_no_admin): + """Test that a revoked wildcard binding is treated as an operator override.""" + mock_entity_client = MagicMock() + wildcard_editor = MagicMock() + wildcard_editor.revoked_at = None + wildcard_viewer = MagicMock() + wildcard_viewer.revoked_at = None + wildcard_creator = MagicMock() + wildcard_creator.revoked_at = datetime.now(timezone.utc) + mock_entity_client.get = AsyncMock(side_effect=[wildcard_editor, wildcard_viewer, wildcard_creator]) + mock_entity_client.create = AsyncMock() + + with patch("nmp.core.auth.app.seeding.get_service_config", return_value=mock_config_no_admin): + result = await run_seeding(mock_entity_client) + + assert result is True + mock_entity_client.create.assert_not_called() diff --git a/services/core/entities/tests/integration/test_workspaces_crud_with_auth.py b/services/core/entities/tests/integration/test_workspaces_crud_with_auth.py index b59e3a9c92..5662ad56c0 100644 --- a/services/core/entities/tests/integration/test_workspaces_crud_with_auth.py +++ b/services/core/entities/tests/integration/test_workspaces_crud_with_auth.py @@ -14,6 +14,7 @@ import uuid from contextlib import contextmanager +from time import monotonic, sleep from typing import Generator import pytest @@ -86,6 +87,43 @@ def as_service(sdk: NeMoPlatform, service_name: str) -> Generator[None, None, No sdk._client.headers.update(old_headers) +def restrict_workspace_creation_to_named_users(sdk: NeMoPlatform) -> None: + """Revoke the seeded wildcard WorkspaceCreator binding while preserving Viewer.""" + with as_service(sdk, "auth"): + sdk.workspaces.members.update( + principal_id="*", + workspace="system", + roles=["Viewer"], + wait_role_propagation=True, + ) + + +def wait_for_workspace_create_authz( + sdk: NeMoPlatform, principal_id: str, expected: bool, timeout_s: float = 5.0 +) -> None: + """Poll the authz allow endpoint until workspace creation reaches the expected decision.""" + deadline = monotonic() + timeout_s + payload = { + "input": { + "principal_id": principal_id, + "method": "POST", + "path": "/apis/entities/v2/workspaces", + "scopes": ["entities:write", "platform:write"], + } + } + + with as_service(sdk, "auth"): + while monotonic() < deadline: + response = sdk._client.post("/apis/auth/v2/authz/allow", json=payload) + assert response.status_code == 200 + allowed = response.json()["result"]["allowed"] + if allowed is expected: + return + sleep(0.1) + + raise AssertionError(f"workspace-create authz for {principal_id} did not become {expected} within {timeout_s}s") + + @pytest.fixture(scope="module") def sdk() -> Generator[NeMoPlatform, None, None]: """SDK client with EntitiesService (auth enabled).""" @@ -147,6 +185,59 @@ def test_create_workspace_with_auth(self, sdk: NeMoPlatform): assert workspace.created_by == TEST_USER_EMAIL assert workspace.updated_by == TEST_USER_EMAIL + def test_workspace_create_remains_open_by_default(self, sdk: NeMoPlatform): + """Default seeding keeps workspace creation open to authenticated users.""" + creator_email = f"creator-{uuid.uuid4().hex[:8]}@example.com" + workspace_name = short_unique_name("default-open") + + with as_user(sdk, creator_email): + created = sdk.workspaces.create(name=workspace_name) + + assert created.name == workspace_name + assert created.created_by == creator_email + + def test_workspace_create_can_be_restricted_by_rebinding_system_role(self, sdk: NeMoPlatform): + """Operators can rebind system workspace creation access to specific principals.""" + creator_email = f"creator-{uuid.uuid4().hex[:8]}@example.com" + workspace_name = short_unique_name("restricted") + seeded_wildcard_roles = ["Viewer", "WorkspaceCreator"] + + try: + restrict_workspace_creation_to_named_users(sdk) + + with as_service(sdk, "auth"): + sdk.workspaces.members.create( + workspace="system", + principal=creator_email, + roles=["Viewer", "WorkspaceCreator"], + wait_role_propagation=True, + ) + + wait_for_workspace_create_authz(sdk, creator_email, expected=True) + + with as_service(sdk, "auth"): + members = sdk.workspaces.members.list(workspace="system") + + wildcard_member = next((m for m in members.data if m.principal == "*"), None) + creator_member = next((m for m in members.data if m.principal == creator_email), None) + + assert wildcard_member is not None + assert set(wildcard_member.roles) == {"Viewer"} + assert creator_member is not None + assert set(creator_member.roles) == {"Viewer", "WorkspaceCreator"} + + with as_user(sdk, creator_email): + created = sdk.workspaces.create(name=workspace_name) + assert created.name == workspace_name + finally: + with as_service(sdk, "auth"): + sdk.workspaces.members.update( + principal_id="*", + workspace="system", + roles=seeded_wildcard_roles, + wait_role_propagation=True, + ) + def test_creator_gets_admin_role(self, sdk: NeMoPlatform): """Test that workspace creator automatically gets Admin role.""" workspace_name = short_unique_name("admin-ws") diff --git a/services/platform-seed/README.md b/services/platform-seed/README.md index b0af48cdce..af655bc3c6 100644 --- a/services/platform-seed/README.md +++ b/services/platform-seed/README.md @@ -1,6 +1,6 @@ # Platform seed task -Platform-centric seeding for NeMo Platform: auth role bindings (platform admin, wildcard default/system), guardrails config store, the default model provider, and plugin-contributed seed jobs discovered from `nemo.seed`. This codebase provides the **task** (run via `nemo-platform run task --task nmp.platform_seed` or as a K8s Job). Optionally, the platform API can run the same seed once at startup when `platform.seed_on_startup` is true in the platform config (or `NMP_SEED_ON_STARTUP=true`). There is no standalone seed service. +Platform-centric seeding for NeMo Platform: auth role bindings (platform admin, wildcard default/system/workspace-creator), guardrails config store, the default model provider, and plugin-contributed seed jobs discovered from `nemo.seed`. This codebase provides the **task** (run via `nemo-platform run task --task nmp.platform_seed` or as a K8s Job). Optionally, the platform API can run the same seed once at startup when `platform.seed_on_startup` is true in the platform config (or `NMP_SEED_ON_STARTUP=true`). There is no standalone seed service. **Layout** (aligned with hello-world): one package `nmp.platform_seed` with `config.py` and `tasks/seed/` for the task (run.py, __main__.py). The task can be run as `python -m nmp.platform_seed` or `python -m nmp.platform_seed.tasks.seed`. @@ -19,7 +19,7 @@ The task uses the same platform config as the rest of the platform (e.g. `NMP_CO |----------|---------|-------------| | `NMP_PLATFORM_SEED_ENABLED` | true | Master switch | | `NMP_PLATFORM_SEED_AUDITOR_ENABLED` | true | Seed auditor configs | -| `NMP_PLATFORM_SEED_AUTH_ENABLED` | true | Seed auth role bindings (platform admin, wildcard default/system) | +| `NMP_PLATFORM_SEED_AUTH_ENABLED` | true | Seed auth role bindings (PlatformAdmin plus wildcard Editor, Viewer, and WorkspaceCreator bindings) | | `NMP_PLATFORM_SEED_GUARDRAILS_ENABLED` | true | Seed guardrail configs | | `NMP_PLATFORM_SEED_MODEL_PROVIDER_ENABLED` | true | Seed nvidia-build model provider | | `NMP_PLATFORM_SEED__ENABLED` | true | Enable or disable an individual plugin seed job discovered under `nemo.seed` (`` is the seed job name uppercased with non-alphanumeric characters replaced by `_`) |