Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/auth/authentication/oidc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/auth/authorization/permissions-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ PlatformAdmin is omitted — it bypasses permission checks entirely at the polic
|------------|-------------|:------:|:------:|:-----:|
| <code>workspaces.(read &#124; list)</code> | Read, list workspaces | ✓ | ✓ | ✓ |
| <code>workspaces.(update &#124; delete)</code> | Update, delete workspaces | | ✓ | ✓ |
| `workspaces.create` | Create workspaces | | | |
| <code>workspaces.members.(list &#124; create &#124; update &#124; delete)</code> | List, create, update, delete workspaces members | | | ✓ |
| `workspaces.members.read` | Read workspace member details | | | |

Expand Down
4 changes: 2 additions & 2 deletions docs/auth/authorization/roles-and-permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,14 @@ 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 | | | ✓ | ✓ |
| Change workspace visibility | | | ✓ | ✓ |

<Note>

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.

</Note>
### Resource Operations (Models, Datasets, Projects)
Expand Down Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions docs/auth/concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment thread
ironcommit marked this conversation as resolved.

## Workspace Visibility

Expand Down
2 changes: 2 additions & 0 deletions docs/auth/deployment/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
34 changes: 34 additions & 0 deletions docs/get-started/concepts/workspaces.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.


<Tabs>

Expand Down Expand Up @@ -56,6 +60,36 @@ nemo workspaces create ml-team \
</Tab>

</Tabs>
## 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/get-started/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
8 changes: 4 additions & 4 deletions services/core/auth/scripts/auth-tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment thread
ironcommit marked this conversation as resolved.

# 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)
Expand Down Expand Up @@ -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"]
Expand Down
12 changes: 5 additions & 7 deletions services/core/auth/src/nmp/core/auth/app/policies/authz.rego
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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}": {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ workspace_test_data := {
"Viewer": {
"permissions": ["workspaces.read", "workspaces.list"]
},
"WorkspaceCreator": {
"permissions": ["workspaces.create"]
},
"Editor": {
"includes": ["Viewer"],
"permissions": ["workspaces.update"]
Expand All @@ -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"]},
Expand All @@ -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
Expand All @@ -59,27 +69,29 @@ 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
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
Expand Down Expand Up @@ -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
Expand All @@ -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"]
}
Loading
Loading