diff --git a/docs/agents/plugins.mdx b/docs/agents/plugins.mdx index 5fc8695d6b..043e654922 100644 --- a/docs/agents/plugins.mdx +++ b/docs/agents/plugins.mdx @@ -15,7 +15,7 @@ can contribute one or more surfaces: | Surface | Entry-point group | What it adds | |---------|-------------------|--------------| -| HTTP service | `nemo.services` | FastAPI routers mounted under `/apis//...` | +| HTTP service | `nemo.services` | FastAPI routers mounted under `/apis//...` — every route must declare authorization (see [Writing Plugins](#writing-plugins)) | | CLI | `nemo.cli` | `nemo ...` commands | | Job | `nemo.jobs` | Local or submitted jobs with generated `run`, `submit`, and `explain` verbs | | Controller | `nemo.controllers` | Background reconciliation loops | @@ -103,5 +103,14 @@ Plugin authors use the `nemo-platform-plugin` package. A minimal plugin declares points in `pyproject.toml`, implements the matching class, and restarts `nemo services run` so the local platform discovers it. +Every plugin HTTP route must declare authorization — a `@path_rule` naming the +permission(s) the caller must hold and the caller kind (`CallerKind.PRINCIPAL` or +`CallerKind.SERVICE_PRINCIPAL`), paired with a `@AuthzScope.read` / `@AuthzScope.write` +scope gate. Routes registered through a job or function route factory declare it by +passing `authz=` instead. A route with no rule fails closed: the platform refuses to +build the policy bundle rather than serve an unguarded endpoint. See +[Plugin Authorization](/documentation/access-control/authorization/plugin-authorization) +for the full pattern. + Keep plugins local-first for OSS 0.2.0: avoid requiring a cluster, container runtime, or external control plane for the documented launch path. diff --git a/docs/auth/authorization/plugin-authorization.mdx b/docs/auth/authorization/plugin-authorization.mdx new file mode 100644 index 0000000000..21abab2020 --- /dev/null +++ b/docs/auth/authorization/plugin-authorization.mdx @@ -0,0 +1,231 @@ +--- +title: "Plugin Authorization" +description: "" +--- +Plugin authorization is derived *entirely from your routes*. You attach an authorization rule to each HTTP route handler — referencing typed permissions and declaring the route's OAuth scope — and the platform reads those rules back to build the permission catalog, the per-endpoint bindings, and the plugin's namespace when it compiles the OPA bundle. There is no separate permission list to keep in sync with your routes. + +This page is for plugin authors. For the platform-wide RBAC model see [Roles & Permissions](/documentation/access-control/authorization/roles-and-permissions); for how decisions are evaluated see [Policy Engine](/documentation/access-control/authorization/policy-engine); for token-level scopes see [API Scopes](/documentation/access-control/authorization/api-scopes). + +## How Plugin Authz Is Derived + +Every plugin HTTP route **must** carry an authorization rule — a `@path_rule` (attached directly, or via a route factory's `authz=`). A route with no rule is a validation error. Under the default fail-mode the auth service refuses to build the OPA bundle rather than silently fencing (or exposing) the route, so a missing rule fails the whole platform closed at startup instead of leaking at request time. + +The rule references `Permission` objects; the platform derives the normalized policy from the routes when the bundle is built: + +- the **permission catalog** (each permission's id and description), +- the **per-endpoint bindings** (method + path → permissions, scope, caller kinds), +- the plugin **namespace**. + +The permission *is* the object referenced on the route, and it carries its own description — so there is nothing to declare twice. The only authz a plugin declares apart from its routes is the optional escape hatch for permissions that have no 1:1 route (see [Permissions Without a Route](#permissions-without-a-route)). + +## The Authorization Model + +Three orthogonal pieces ride on a route: the **permissions** it requires, the **OAuth scope** it sits behind, and the **caller kinds** allowed to satisfy it. + +### Permissions — the `service.resource.action` grammar + +A permission id follows the grammar `..`, joined from its parts: + +- `service` — the owning plugin. It **must** equal your `NemoService.name`; this namespace fence is enforced fail-closed (an out-of-namespace permission fails the whole plugin). +- `resource` — the collection or sub-resource acted on. May be empty for a service-level action, and may itself be dotted. +- `action` — the operation (`create` / `list` / `read` / `update` / `delete`, …). + +You never hand-build id strings. Declare permissions in a typed `PermissionSet` and reference the members: + +```python +from nemo_platform_plugin.authz import PermissionSet, perm + + +class ItemPerms(PermissionSet, namespace="myplugin.items"): + CREATE = perm("Create example items") # -> id "myplugin.items.create" + LIST = perm("List example items") # -> id "myplugin.items.list" + READ = perm("Read an example item") # -> id "myplugin.items.read" +``` + +Inside a `PermissionSet(namespace="a.b")`, `perm("desc")` mints the id `a.b.`. For a compound id, pass `suffix=`: + +```python +class DeploymentPerms(PermissionSet, namespace="deployments.deployments"): + STATUS_UPDATE = perm( + "Update deployment observed status (controller)", + suffix="status.update", # -> id "deployments.deployments.status.update" + ) +``` + +Referencing a `PermissionSet` member that doesn't exist is an `AttributeError` at import, and passing a bare string where a `Permission` is expected is a `TypeError` at decoration — a permission typo can never reach the policy layer. + +### OAuth scopes + +A plugin owns one `AuthzScope`, which mints both the coarse OAuth scope and the permission namespace beneath it. `AuthzScope("myplugin")` gives the route decorators `@scope.read` and `@scope.write`, which stamp the scope requirement `myplugin:read` / `myplugin:write` (plus the catch-all `platform:read` / `platform:write`). A route carries exactly one scope. + +When the permission namespace nests deeper than the OAuth scope, use `child()` — the scope stays put while the permission id deepens: + +```python +from nemo_platform_plugin.authz import AuthzScope + +scope = AuthzScope("agents") +scope.child("deployments").permission("create", description="Create a deployment") +# -> Permission id "agents.deployments.create"; OAuth scope stays "agents" +``` + +### Caller kinds — PRINCIPAL and SERVICE_PRINCIPAL + +`CallerKind` is a subject attribute enforced in Rego, not a permission. A route is intended for a normal authenticated user (`CallerKind.PRINCIPAL`) or a caller whose id is prefixed `service:` — e.g. a controller writing observed status (`CallerKind.SERVICE_PRINCIPAL`). There is intentionally no anonymous kind; the only genuinely public routes are core infrastructure, hardcoded as a bypass in the enforcement point. + +## Declaring Authz on a Route + +For a hand-written route, `@router.` is the **outermost** (top) decorator; `@scope.read`/`@scope.write` and `@path_rule` sit under it. The scope decorator and the path rule are independent, so their order relative to each other does not matter. + +```python +from fastapi import APIRouter +from nemo_platform_plugin.authz import AuthzScope, CallerKind, PermissionSet, path_rule, perm +from nemo_platform_plugin.service import NemoService, RouterSpec + +scope = AuthzScope("example") + + +class ExamplePerms(PermissionSet, namespace="example"): + READ = perm("Read example items") # -> Permission("example.read", ...) + + +router = APIRouter() + + +@router.get("/v2/workspaces/{workspace}/items/{name}") +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[ExamplePerms.READ]) +async def get_item(workspace: str, name: str) -> dict: ... + + +class ExampleService(NemoService): + name = "example" + + def get_routers(self) -> list[RouterSpec]: + return [RouterSpec(router)] +``` + +`@path_rule` takes: + +- `callers` — a required, non-empty list of caller kinds. They are **OR'd**. +- `permissions` — `Permission` objects the caller must hold. They are **AND'd**, and may be `[]` for an authenticated-but-permissionless route (e.g. a health check or a raw blob `PUT`). + +Stacking `@path_rule` on the same handler adds OR-alternatives: any satisfied rule allows the request. + +A controller-only write route pins the caller kind to `SERVICE_PRINCIPAL` and requires the compound permission: + +```python +from nemo_platform_plugin.authz import CallerKind, path_rule + + +@router.put("/deployments/{name}/status", response_model=Deployment) +@scope.write +@path_rule(callers=[CallerKind.SERVICE_PRINCIPAL], permissions=[DeploymentPerms.STATUS_UPDATE]) +async def update_deployment_status(workspace: str, name: str, ...) -> Deployment: ... +``` + +An authenticated-but-permissionless route passes `permissions=[]`: + +```python +@router.put("/blob/{name}") +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[]) +async def upload_blob(name: str, request: Request) -> BlobUploadResponse: ... +``` + +## Job and Function Routes + +Jobs and functions are mounted through route factories rather than hand-written handlers. You **must** pass `authz=` — the kwarg defaults to `None`, and a factory called without it leaves its routes unruled, which fails closed at bundle time. + +```python +from nemo_platform_plugin.authz import AuthzScope +from nemo_platform_plugin.functions.routes import add_function_routes +from nemo_platform_plugin.jobs.routes import add_job_routes + +scope = AuthzScope("myplugin") + +add_job_routes(MyJob, authz=scope) + +add_function_routes( + GreetFunction, + authz=scope, + permission_description="Invoke the greet function", +) +``` + +When `authz=` is set, `add_function_routes` stamps a `PRINCIPAL` rule on the route and mints an invoke permission from the scope (`.`, a write action). `permission_description` is optional prose for that permission and requires `authz` — supplying it alone is a `ValueError`. + +## Permissions Without a Route + +Some permissions are not 1:1 with a route — they are checked in middleware, declared ahead of the route that will reference them, or need to be granted to a role the default heuristic misses. Declare these on your `NemoService` via two escape hatches; both are merged into the derived catalog. + +```python +from nemo_platform_plugin.authz import Permission +from nemo_platform_plugin.service import NemoService, RouterSpec + + +class MyService(NemoService): + name = "myplugin" + + def get_routers(self) -> list[RouterSpec]: ... + + def extra_permissions(self) -> list[Permission]: + # Permissions with no 1:1 route (e.g. checked in middleware). + return MiddlewarePerms.all() + + def extra_role_permissions(self) -> dict[str, list[Permission]]: + # Grant a permission to a role the suffix heuristic wouldn't reach. + return {"Viewer": [MyFunctionPerms.INVOKE]} +``` + +The derivation grants each catalog permission to roles by a suffix heuristic: + +| Permission id ends with | Granted to | +|-------------------------|------------| +| `.list` or `.read` | Viewer + Editor | +| everything else | Editor only | + +Use `extra_role_permissions` when that heuristic is wrong — e.g. an agent gateway's `.invoke` permission that a Viewer must hold to call a deployed agent even though its suffix isn't `read`. Grants are **unioned** with the suffix defaults (never subtractive), every granted permission must live in this service's own namespace (or the whole plugin fails closed), and a permission granted here is registered in the catalog automatically, so it need not also appear in `extra_permissions`. + +## When a Plugin's Authz Is Invalid + +An unruled route, or a rule referencing an undeclared or out-of-namespace permission, makes a plugin's authz invalid. The offending routes are always emitted as explicit denies; the auth service's `on_invalid_plugin` fail-mode (env prefix `NMP_AUTH_`) controls the blast radius: + +| `NMP_AUTH_ON_INVALID_PLUGIN` | Behavior | +|------------------------------|----------| +| `hard_fail` (default) | Refuse to build the OPA bundle — fail closed at the platform level. | +| `quarantine` | Deny the whole offending plugin, but keep the platform up. | +| `deny_route` | Deny only the plugin's bad routes. | + + + +The default `hard_fail` matches the rule that a missing path rule is a validation error. A deployment that loads dynamically-discovered or third-party plugins CI never vetted can downgrade to `quarantine` or `deny_route` so one bad plugin can't wedge the platform. + + + +## Verifying Your Plugin's Authz + +From the repo root, with the workspace installed, check that every route is ruled: + +```bash +uv run python services/core/auth/scripts/auth-tools.py sync-plugins --dry-run +# -> reports 0 degraded plugins when every route is ruled +``` + +Then rebuild and test the compiled policy: + +```bash +make build-policy && make test-policy +``` + + + +The compiled `policy.wasm` in the auth service is a gitignored build artifact. Run `make build-policy` after pulling authz or Rego changes — otherwise the embedded-PDP tests run against a stale copy and fail with misleading errors. + + + +## Related + +- [Roles & Permissions](/documentation/access-control/authorization/roles-and-permissions) — Predefined roles and how permissions map to them. +- [Permissions Reference](/documentation/access-control/authorization/permissions-reference) — The full catalog of derived permissions across every API. +- [Policy Engine](/documentation/access-control/authorization/policy-engine) — How the PDP evaluates permissions, scopes, and role bindings. +- [API Scopes](/documentation/access-control/authorization/api-scopes) — Token-level scope restrictions layered on top of role permissions. diff --git a/docs/fern/versions/latest.yml b/docs/fern/versions/latest.yml index c8a9d64707..7aef6a272b 100644 --- a/docs/fern/versions/latest.yml +++ b/docs/fern/versions/latest.yml @@ -57,6 +57,8 @@ navigation: path: ../../auth/authorization/managing-access.mdx - page: Permissions Reference path: ../../auth/authorization/permissions-reference.mdx + - page: Plugin Authorization + path: ../../auth/authorization/plugin-authorization.mdx - page: Policy Engine path: ../../auth/authorization/policy-engine.mdx - page: Roles and Permissions