Declares HTTP authorization on NeMo Platform plugin routes with @path_rule, AuthzScope, PermissionSet, and CallerKind. Use when adding or securing a plugin route, minting permission ids, choosing PRINCIPAL vs SERVICE_PRINCIPAL callers, fixing a hard_fail bundle build from an unruled route, or migrating off get_authz_contribution. Trigger keywords: authz, authorization, permission, path_rule, AuthzScope, PermissionSet, perm, CallerKind, SERVICE_PRINCIPAL, scope, hard_fail, on_invalid_plugin, unruled route, bundle build, get_authz_contribution.
Declares HTTP authorization on NeMo Platform plugin routes with @path_rule, AuthzScope, PermissionSet, and CallerKind. Use when adding or securing a plugin route, minting permission ids, choosing PRINCIPAL vs SERVICE_PRINCIPAL callers, fixing a hard_fail bundle build from an unruled route, or migrating off get_authz_contribution. Trigger keywords: authz, authorization, permission, path_rule, AuthzScope, PermissionSet, perm, CallerKind, SERVICE_PRINCIPAL, scope, hard_fail, on_invalid_plugin, unruled route, bundle build, get_authz_contribution.
Authz is derived entirely from the routes. Each handler carries two orthogonal decorators — a @path_rule (who may call + what they must hold) and an @AuthzScope.read/.write (the OAuth scope gate). The platform reads them off the mounted routes to build the permission catalog, the per-endpoint bindings, and the OPA bundle. There is no separate permission list to keep in sync.
THE RULE
Every plugin HTTP route MUST carry a @path_rule — directly, or via a route factory you passed authz= to. A route with no rule is a validation error: the offending routes are emitted as explicit deny markers (they override every allow, including the service * wildcard and the PlatformAdmin bypass), and under the default on_invalid_plugin="hard_fail" the auth service refuses to build the OPA bundle — the platform 502s rather than silently fencing the route.
If you add a route, you add a rule. If the platform starts 502-ing after you touch a plugin, suspect an unruled route first.
Decorator order:@router.<method> is the OUTERMOST (top) decorator; @scope.read and @path_rule sit under it. Their order relative to each other does not matter — both only stamp attributes on the handler and return it unchanged (identity survives FastAPI's include_router(prefix=...) rebasing).
Within a rule, callers are OR'd and permissions are AND'd.
callers is required and non-empty — there is no callers=[] (it raises at import). To allow more than one caller kind, list them in one rule: callers=[CallerKind.PRINCIPAL, CallerKind.SERVICE_PRINCIPAL].
permissions may be [] for an authenticated-but-permissionless route (e.g. /healthz, a blob PUT).
Stacking @path_rule on one handler adds OR-alternatives, but v1 ORs only the caller dimension: stacked rules must share the same permissions (an OR of distinct permission sets is rejected at derivation). "PRINCIPAL needs A, SERVICE_PRINCIPAL needs B" on one route isn't expressible — use one rule with shared permissions.
Reference PermissionSet members, never bare strings — a string raises TypeError at import, so a permission typo can't reach the policy layer.
Permission ids — PermissionSet + perm()
Id grammar is <service>.<resource>.<action>, joined from parts. The <service> (first segment) MUST equal NemoService.name — that namespace fence is enforced, or the whole plugin fails closed. Segments split on dots only; hyphens are fine within a segment, so a hyphenated name like my-plugin yields my-plugin.widgets.create — three segments (my-plugin / widgets / create), not four.
classItemPerms(PermissionSet, namespace="myplugin.items"):
CREATE = perm("Create an item") # id -> "myplugin.items.create"
STATUS_UPDATE = perm("Set observed status",
suffix="status.update") # id -> "myplugin.items.status.update"
Inside PermissionSet(namespace="a.b"), perm("desc") gives id a.b.<member-name-lowercased>.
perm("desc", suffix="configs.create") mints a compound id a.b.configs.create.
PermissionSet.all() returns every member (handy for extra_permissions).
@AuthzScope.read / .write — the scope gate
A route carries exactly one OAuth scope, declared separately from @path_rule. AuthzScope("agents") owns agents:read / agents:write (each expands to ["<area>:<verb>", "platform:<verb>"]). Re-stamping the same scope is idempotent; a different scope on the same handler is a ValueError at import.
Mint permissions off the same scope when the permission namespace nests deeper than the scope:
AuthzScope("agents").child("deployments").permission("create", description="...")
# -> Permission id "agents.deployments.create"; the OAuth scope stays "agents"
Share one AuthzScope across a plugin's route modules (define it in a small authz.py and import it) so the scope and namespace live in one place.
Caller kinds — who may call the route
callers is a required, non-empty list on every @path_rule; it names which kinds of authenticated caller the rule applies to (multiple are OR'd). Caller-kind is a PDP subject attribute enforced in Rego, not a permission.
Set callers to
Satisfied by
Use it for
[CallerKind.PRINCIPAL]
a normal authenticated user (a human, or any user token)
the baseline — user-facing CRUD, reads, and actions a person invokes
[CallerKind.SERVICE_PRINCIPAL]
a caller whose id is prefixed service: (another service or a controller)
machine-to-machine routes a user must never call directly, e.g. a controller writing observed status
routes hit by both people and services (e.g. a read a controller also consumes)
If you are not writing a machine-only route, use PRINCIPAL — it is the baseline posture, and every rule states its callers explicitly (there is no "unset" at the @path_rule level). Pinning a route to SERVICE_PRINCIPAL alone denies a human user even when they hold the right permission. There is no anonymous or public caller kind: a plugin route always requires an authenticated caller, and genuinely public endpoints are core-infra bypasses, not plugin routes.
The deployments controller-status PUT is the canonical service-principal-only route — only the controller (service:...) may write observed status:
DeploymentPerms.STATUS_UPDATE = perm("Update deployment observed status (controller)", suffix="status.update") under namespace="deployments.deployments" → id deployments.deployments.status.update.
Route factories — pass authz= or the routes are UNRULED
add_job_routes / add_function_routes stamp the rule for you, but the authz= kwarg defaults to None — omit it and every generated route is unruled (denied fail-closed at bundle time).
from nemo_platform_plugin.jobs.routes import add_job_routes
from nemo_platform_plugin.functions.routes import add_function_routes
add_job_routes(MyJob, authz=AuthzScope("myplugin"))
add_function_routes(MyFn, authz=AuthzScope("myplugin"), permission_description="Invoke MyFn")
Invoking a function is a write action: the factory mints a PRINCIPAL permission <namespace>.<function-name> and stamps @scope.write. permission_description defaults to the function's own description and requires authz (supplying it alone is a ValueError).
Escape hatches — permissions with no 1:1 route
Override on your NemoService for permissions that aren't attached to a route (checked in middleware, or granted to a role the heuristic misses):
Default role-grant heuristic: ids ending .list or .read → Viewer + Editor; everything else → Editor only. Use extra_role_permissions to reach a role the heuristic wouldn't — e.g. granting a Viewer an .invoke permission (its suffix isn't read). Grants are unioned with the defaults (never subtractive), each granted permission is also auto-registered in the catalog (no need to also list it in extra_permissions), and every permission must live in this service's own namespace or the whole plugin fails closed.
Fail modes — NMP_AUTH_ON_INVALID_PLUGIN
The offending routes are always emitted as explicit denies; the auth-service config field on_invalid_plugin (env NMP_AUTH_ON_INVALID_PLUGIN, prefix NMP_AUTH_) only controls the blast radius:
Value
Behaviour
hard_fail (default)
Refuse to build the OPA bundle — fail closed at the platform level (platform 502s).
quarantine
Deny the whole offending plugin, but keep the platform up.
deny_route
Deny only the bad routes.
A deployment loading dynamically-discovered or third-party plugins CI never vetted can downgrade to quarantine / deny_route so one bad plugin can't wedge the platform.
Verify
# From repo root, workspace installed. Reports 0 degraded plugins when every route is ruled:
uv run python services/core/auth/scripts/auth-tools.py sync-plugins --dry-run
make build-policy && make test-policy
Local dev:services/core/auth/.../assets/policy.wasm is a gitignored build artifact. Run make build-policy after pulling authz/Rego changes, otherwise test_embedded_pdp.py fails against a stale copy (misleading sprintf / fence-fails-open errors).
For a fast in-process check with no running platform, add a tests/test_authz.py that derives your service's contribution and asserts it's clean (copy any plugin's tests/test_authz.py):
from nemo_platform_plugin.authz_discovery import _derive_service_contribution
deftest_all_routes_are_ruled():
contrib, errors, _warnings = _derive_service_contribution(MyService())
assertnot errors
assertnotany(m.deny for methods in contrib.endpoints.values() for m in methods.values())
Migrating off get_authz_contribution
The old surface is gone: the get_authz_contribution() classmethod and the nemo.authz entry point were removed. To migrate a plugin:
Delete get_authz_contribution() and drop the nemo.authz entry point.
Move each permission id into a PermissionSet — ids are preserved, so existing role grants do not change.
Attach @path_rule plus @scope.read / @scope.write to every handler (or authz= on the route factory).
See Also
examples.md — full copy-paste recipes (hand-written CRUD, service-principal status route, job/function factory wiring, escape hatches)