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 `_`) |