Skip to content

Commit e0a2506

Browse files
committed
feat(deployments): migrate plugin authz to @path_rule surface
The deployments plugin still declared authz through the removed get_authz_contribution() classmethod, which the routes-derived discovery pipeline ignores, leaving all 14 routes unruled (silently fenced under deny_route, a bundle-build abort under the new hard_fail default). Declare permissions as typed PermissionSets and attach @path_rule to every route; the controller-only status PUTs require SERVICE_PRINCIPAL (the existing require_service_principal dependency stays as defense-in-depth). Permission ids are preserved exactly, so role grants are unchanged. Drop the old classmethod and assert per-route rule coverage in the startup test. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent 6c65a25 commit e0a2506

8 files changed

Lines changed: 88 additions & 76 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Typed permission vocabulary for the deployments plugin's routes.
5+
6+
Three sub-namespaces under ``deployments`` (one per entity collection). Route handlers
7+
reference these constants in their ``@path_rule``; the platform derives the permission
8+
catalog from the routes, so there is no parallel list to keep in sync. The controller-only
9+
status routes mint a ``status.update`` permission under the collection they project onto.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from nemo_platform_plugin.authz import PermissionSet, perm
15+
16+
17+
class DeploymentConfigPerms(PermissionSet, namespace="deployments.deployment-configs"):
18+
CREATE = perm("Create deployments deployment-configs")
19+
LIST = perm("List deployments deployment-configs")
20+
READ = perm("Read deployments deployment-configs")
21+
DELETE = perm("Delete deployments deployment-configs")
22+
23+
24+
class DeploymentPerms(PermissionSet, namespace="deployments.deployments"):
25+
CREATE = perm("Create deployments deployments")
26+
LIST = perm("List deployments deployments")
27+
READ = perm("Read deployments deployments")
28+
DELETE = perm("Delete deployments deployments")
29+
STATUS_UPDATE = perm("Update deployment observed status (controller)", suffix="status.update")
30+
31+
32+
class VolumePerms(PermissionSet, namespace="deployments.volumes"):
33+
CREATE = perm("Create deployments volumes")
34+
LIST = perm("List deployments volumes")
35+
READ = perm("Read deployments volumes")
36+
DELETE = perm("Delete deployments volumes")
37+
STATUS_UPDATE = perm("Update volume observed status (controller)", suffix="status.update")

plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployment_configs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import logging
99

1010
from fastapi import APIRouter, Depends, HTTPException, Query
11+
from nemo_deployments_plugin.api.v2._perms import DeploymentConfigPerms
1112
from nemo_deployments_plugin.api.v2.dependencies import get_entity_client
13+
from nemo_deployments_plugin.authz import SCOPE
1214
from nemo_deployments_plugin.entities import DeploymentConfig
1315
from nemo_deployments_plugin.references import deployment_names_using_config
1416
from nemo_deployments_plugin.schema import (
@@ -23,6 +25,7 @@
2325
prerequisite_names,
2426
)
2527
from nemo_platform_plugin.api.filters import make_filter_obj_dep
28+
from nemo_platform_plugin.authz import CallerKind, path_rule
2629
from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError
2730
from nemo_platform_plugin.schema import PaginationData
2831

@@ -55,6 +58,7 @@ async def _list_all_deployment_configs(
5558

5659

5760
@router.post("/deployment-configs", response_model=DeploymentConfig, status_code=201, tags=["Deployment Configs"])
61+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentConfigPerms.CREATE], scopes=SCOPE.write())
5862
async def create_deployment_config(
5963
workspace: str,
6064
body: CreateDeploymentConfigRequest,
@@ -87,6 +91,7 @@ async def create_deployment_config(
8791

8892

8993
@router.get("/deployment-configs", response_model=DeploymentConfigPage, tags=["Deployment Configs"])
94+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentConfigPerms.LIST], scopes=SCOPE.read())
9095
async def list_deployment_configs(
9196
workspace: str,
9297
page: int = Query(default=1, ge=1),
@@ -109,6 +114,7 @@ async def list_deployment_configs(
109114

110115

111116
@router.get("/deployment-configs/{name}", response_model=DeploymentConfig, tags=["Deployment Configs"])
117+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentConfigPerms.READ], scopes=SCOPE.read())
112118
async def get_deployment_config(
113119
workspace: str,
114120
name: str,
@@ -124,6 +130,7 @@ async def get_deployment_config(
124130

125131

126132
@router.delete("/deployment-configs/{name}", status_code=204, tags=["Deployment Configs"])
133+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentConfigPerms.DELETE], scopes=SCOPE.write())
127134
async def delete_deployment_config(
128135
workspace: str,
129136
name: str,

plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/deployments.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
from typing import cast
1010

1111
from fastapi import APIRouter, Depends, HTTPException, Query
12+
from nemo_deployments_plugin.api.v2._perms import DeploymentPerms
1213
from nemo_deployments_plugin.api.v2.dependencies import get_entity_client
14+
from nemo_deployments_plugin.authz import SCOPE
1315
from nemo_deployments_plugin.entities import Deployment, DeploymentConfig, DeploymentStatus
1416
from nemo_deployments_plugin.schema import CreateDeploymentRequest, DeploymentFilter, DeploymentPage
1517
from nemo_platform_plugin.api.filters import make_filter_obj_dep
18+
from nemo_platform_plugin.authz import CallerKind, path_rule
1619
from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError
1720
from nemo_platform_plugin.filter_ops import ComparisonOperation, FilterOperator
1821
from nemo_platform_plugin.schema import PaginationData
@@ -55,6 +58,7 @@ def _parse_deployment_config_ref(ref: str, default_workspace: str) -> tuple[str,
5558

5659

5760
@router.post("/deployments", response_model=Deployment, status_code=201, tags=["Deployments"])
61+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentPerms.CREATE], scopes=SCOPE.write())
5862
async def create_deployment(
5963
workspace: str,
6064
body: CreateDeploymentRequest,
@@ -87,6 +91,7 @@ async def create_deployment(
8791

8892

8993
@router.get("/deployments", response_model=DeploymentPage, tags=["Deployments"])
94+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentPerms.LIST], scopes=SCOPE.read())
9095
async def list_deployments(
9196
workspace: str,
9297
page: int = Query(default=1, ge=1),
@@ -122,6 +127,7 @@ async def list_deployments(
122127

123128

124129
@router.get("/deployments/{name}", response_model=Deployment, tags=["Deployments"])
130+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentPerms.READ], scopes=SCOPE.read())
125131
async def get_deployment(
126132
workspace: str,
127133
name: str,
@@ -137,6 +143,7 @@ async def get_deployment(
137143

138144

139145
@router.delete("/deployments/{name}", status_code=204, tags=["Deployments"])
146+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[DeploymentPerms.DELETE], scopes=SCOPE.write())
140147
async def delete_deployment(
141148
workspace: str,
142149
name: str,

plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/status.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@
66
from __future__ import annotations
77

88
from fastapi import APIRouter, Depends, HTTPException
9+
from nemo_deployments_plugin.api.v2._perms import DeploymentPerms, VolumePerms
910
from nemo_deployments_plugin.api.v2.dependencies import get_entity_client, require_service_principal
11+
from nemo_deployments_plugin.authz import SCOPE
1012
from nemo_deployments_plugin.entities import Deployment, Volume
1113
from nemo_deployments_plugin.schema import UpdateDeploymentStatusRequest, UpdateVolumeStatusRequest
14+
from nemo_platform_plugin.authz import CallerKind, path_rule
1215
from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError
1316

1417
router = APIRouter()
1518

1619

1720
@router.put("/deployments/{name}/status", response_model=Deployment, tags=["Deployment Status"])
21+
@path_rule(callers=[CallerKind.SERVICE_PRINCIPAL], permissions=[DeploymentPerms.STATUS_UPDATE], scopes=SCOPE.write())
1822
async def update_deployment_status(
1923
workspace: str,
2024
name: str,
@@ -47,6 +51,7 @@ async def update_deployment_status(
4751

4852

4953
@router.put("/volumes/{name}/status", response_model=Volume, tags=["Volume Status"])
54+
@path_rule(callers=[CallerKind.SERVICE_PRINCIPAL], permissions=[VolumePerms.STATUS_UPDATE], scopes=SCOPE.write())
5055
async def update_volume_status(
5156
workspace: str,
5257
name: str,

plugins/nemo-deployments/src/nemo_deployments_plugin/api/v2/volumes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
from __future__ import annotations
77

88
from fastapi import APIRouter, Depends, HTTPException, Query
9+
from nemo_deployments_plugin.api.v2._perms import VolumePerms
910
from nemo_deployments_plugin.api.v2.dependencies import get_entity_client
11+
from nemo_deployments_plugin.authz import SCOPE
1012
from nemo_deployments_plugin.entities import Volume
1113
from nemo_deployments_plugin.references import deployment_config_names_referencing_volume
1214
from nemo_deployments_plugin.schema import CreateVolumeRequest, VolumeFilter, VolumePage
1315
from nemo_platform_plugin.api.filters import make_filter_obj_dep
16+
from nemo_platform_plugin.authz import CallerKind, path_rule
1417
from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError
1518
from nemo_platform_plugin.schema import PaginationData
1619

@@ -20,6 +23,7 @@
2023

2124

2225
@router.post("/volumes", response_model=Volume, status_code=201, tags=["Volumes"])
26+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[VolumePerms.CREATE], scopes=SCOPE.write())
2327
async def create_volume(
2428
workspace: str,
2529
body: CreateVolumeRequest,
@@ -41,6 +45,7 @@ async def create_volume(
4145

4246

4347
@router.get("/volumes", response_model=VolumePage, tags=["Volumes"])
48+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[VolumePerms.LIST], scopes=SCOPE.read())
4449
async def list_volumes(
4550
workspace: str,
4651
page: int = Query(default=1, ge=1),
@@ -63,6 +68,7 @@ async def list_volumes(
6368

6469

6570
@router.get("/volumes/{name}", response_model=Volume, tags=["Volumes"])
71+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[VolumePerms.READ], scopes=SCOPE.read())
6672
async def get_volume(
6773
workspace: str,
6874
name: str,
@@ -78,6 +84,7 @@ async def get_volume(
7884

7985

8086
@router.delete("/volumes/{name}", status_code=204, tags=["Volumes"])
87+
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[VolumePerms.DELETE], scopes=SCOPE.write())
8188
async def delete_volume(
8289
workspace: str,
8390
name: str,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""The deployments plugin's authz scope.
5+
6+
The route modules import :data:`SCOPE` so the plugin shares one ``AuthzScope("deployments")``.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from nemo_platform_plugin.authz import AuthzScope
12+
13+
SCOPE = AuthzScope("deployments")

plugins/nemo-deployments/src/nemo_deployments_plugin/service.py

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,11 @@
1111
from nemo_deployments_plugin.backends.registry import ExecutorRegistry, ExecutorSpec
1212
from nemo_deployments_plugin.config import DeploymentsConfig
1313
from nemo_platform import AsyncNeMoPlatform
14-
from nemo_platform_plugin.authz import AuthzContribution, AuthzEndpointMethod
1514
from nemo_platform_plugin.sdk_provider import get_async_platform_sdk
1615
from nemo_platform_plugin.service import NemoService, RouterSpec
1716

1817
logger = logging.getLogger(__name__)
1918

20-
_SERVICE_NAME = "deployments"
21-
_READ_SCOPES = [f"{_SERVICE_NAME}:read", "platform:read"]
22-
_WRITE_SCOPES = [f"{_SERVICE_NAME}:write", "platform:write"]
23-
24-
25-
def _read_method(permission: str) -> AuthzEndpointMethod:
26-
return AuthzEndpointMethod(permissions=[permission], scopes=list(_READ_SCOPES))
27-
28-
29-
def _write_method(permission: str) -> AuthzEndpointMethod:
30-
return AuthzEndpointMethod(permissions=[permission], scopes=list(_WRITE_SCOPES))
31-
32-
33-
def _read_methods(permission: str) -> dict[str, AuthzEndpointMethod]:
34-
return {method: _read_method(permission) for method in ("get", "head")}
35-
3619

3720
class DeploymentsService(NemoService):
3821
"""HTTP service for deployment configs, deployments, volumes, and controller status."""
@@ -49,52 +32,6 @@ def executor_registry(self) -> ExecutorRegistry:
4932
self._executor_registry = ExecutorRegistry.empty()
5033
return self._executor_registry
5134

52-
@classmethod
53-
def get_authz_contribution(cls) -> AuthzContribution:
54-
"""Authorization policy for deployments plugin routes."""
55-
base = f"/apis/{cls.name}/v2/workspaces/{{workspace}}"
56-
permissions: dict[str, str] = {}
57-
endpoints: dict[str, dict[str, AuthzEndpointMethod]] = {}
58-
59-
for resource, path_segment in (
60-
("deployment-configs", "deployment-configs"),
61-
("deployments", "deployments"),
62-
("volumes", "volumes"),
63-
):
64-
create_perm = f"{cls.name}.{resource}.create"
65-
list_perm = f"{cls.name}.{resource}.list"
66-
read_perm = f"{cls.name}.{resource}.read"
67-
delete_perm = f"{cls.name}.{resource}.delete"
68-
permissions.update(
69-
{
70-
create_perm: f"Create {cls.name} {resource}",
71-
list_perm: f"List {cls.name} {resource}",
72-
read_perm: f"Read {cls.name} {resource}",
73-
delete_perm: f"Delete {cls.name} {resource}",
74-
}
75-
)
76-
endpoints[f"{base}/{path_segment}"] = {
77-
**_read_methods(list_perm),
78-
"post": _write_method(create_perm),
79-
}
80-
endpoints[f"{base}/{path_segment}/{{name}}"] = {
81-
"delete": _write_method(delete_perm),
82-
**_read_methods(read_perm),
83-
}
84-
85-
deployment_status_perm = f"{cls.name}.deployments.status.update"
86-
volume_status_perm = f"{cls.name}.volumes.status.update"
87-
permissions[deployment_status_perm] = "Update deployment observed status (controller)"
88-
permissions[volume_status_perm] = "Update volume observed status (controller)"
89-
endpoints[f"{base}/deployments/{{name}}/status"] = {
90-
"put": _write_method(deployment_status_perm),
91-
}
92-
endpoints[f"{base}/volumes/{{name}}/status"] = {
93-
"put": _write_method(volume_status_perm),
94-
}
95-
96-
return AuthzContribution(permissions=permissions, endpoints=endpoints)
97-
9835
def get_routers(self) -> list[RouterSpec]:
9936
from nemo_deployments_plugin.api.v2 import (
10037
deployment_configs,

plugins/nemo-deployments/tests/unit/test_service_startup.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from fastapi.routing import APIRoute
77
from nemo_deployments_plugin.service import DeploymentsService
8+
from nemo_platform_plugin.authz import get_path_rules
89

910

1011
def _mounted_paths() -> set[str]:
@@ -31,16 +32,14 @@ def test_service_name_matches_entry_point() -> None:
3132

3233

3334
def test_service_authz_covers_mounted_routes() -> None:
34-
contribution = DeploymentsService.get_authz_contribution()
35-
endpoint_paths = set(contribution.endpoints.keys())
36-
for path in _mounted_paths():
37-
assert path in endpoint_paths, f"missing authz entry for {path}"
38-
route_methods = {
39-
method.lower()
40-
for spec in DeploymentsService().get_routers()
41-
for route in spec.router.routes
42-
if isinstance(route, APIRoute) and f"/apis/deployments{spec.prefix}{route.path}" == path
43-
for method in route.methods or set()
44-
}
45-
for method in route_methods:
46-
assert method in contribution.endpoints[path], f"missing authz method {method} for {path}"
35+
"""Every mounted route carries at least one ``@path_rule``.
36+
37+
Authz is derived from the routes (no separate contribution); an unruled route would be
38+
treated as invalid and fenced/denied by the PDP, so coverage is the property to assert.
39+
"""
40+
service = DeploymentsService()
41+
for spec in service.get_routers():
42+
for route in spec.router.routes:
43+
if isinstance(route, APIRoute):
44+
full = f"/apis/deployments{spec.prefix}{route.path}"
45+
assert get_path_rules(route.endpoint), f"missing @path_rule for {full}"

0 commit comments

Comments
 (0)