From 2375a9f74d0225297a52fbb56076e0be7ab5ef1f Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 16:38:17 -0800 Subject: [PATCH 01/12] claude: draft 1 --- docs/adding-authz.adoc | 1063 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1063 insertions(+) create mode 100644 docs/adding-authz.adoc diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc new file mode 100644 index 00000000000..c2bca6b6e99 --- /dev/null +++ b/docs/adding-authz.adoc @@ -0,0 +1,1063 @@ += Adding Authorization for Resources +:toc: left +:toclevels: 3 + +== Overview + +This document explains how to add authorization (authz) support for new resources in Omicron. Authorization in Omicron is based on role-based access control (RBAC) using the Oso policy engine. The implementation spans multiple layers of the codebase, from defining Rust types to specifying Polar policy rules. + +Before implementing authz for a new resource, you should understand the basic concepts described in the module comments of `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs`. + +== Resource Categories + +Resources in Omicron fall into four main categories, each requiring a slightly different approach: + +1. **Static top-level resources** - Singleton resources at the top of the hierarchy (e.g., Fleet, system inventory) +2. **Dynamic top-level resources** - Multiple instances of a resource type at the top level (e.g., Silo) +3. **Static child resources** - Synthetic collection resources beneath a dynamic parent (e.g., Silo's certificate list) +4. **Dynamic nested resources** - Resources with multiple instances nested under other dynamic resources (e.g., Project under Silo, Instance under Project) + +The approach you take depends on which category your resource falls into. + +== Static Top-Level Resources + +Static top-level resources are singletons representing system-wide concepts. Examples include `Fleet`, `Inventory`, and `DnsConfig`. + +=== Characteristics + +* There is only one instance in the entire system +* Typically represented as a unit struct or zero-sized type +* Usually require fleet-level admin privileges +* Do not support role assignments (roles are on the Fleet instead) + +=== Implementation Steps + +==== 1. Define the Rust Type + +In `nexus/auth/src/authz/api_resources.rs`, define your type as a unit struct and create a singleton constant: + +[source,rust] +---- +/// Synthetic resource used for modeling access to low-level hardware inventory +/// data +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Inventory; +pub const INVENTORY: Inventory = Inventory {}; +---- + +==== 2. Implement PolarClass + +Implement `oso::PolarClass` to define how Oso interacts with this type. For most static resources, you'll want to: + +* Mark it with equality checking +* Add a method to check roles (usually returns `false` since roles are on the parent) +* Add an attribute getter for the parent (typically `fleet`) + +[source,rust] +---- +impl oso::PolarClass for Inventory { + fn get_polar_class_builder() -> oso::ClassBuilder { + // Roles are not directly attached to Inventory + oso::Class::builder() + .with_equality_check() + .add_method( + "has_role", + |_: &Inventory, _actor: AuthenticatedActor, _role: String| { + false + }, + ) + .add_attribute_getter("fleet", |_| FLEET) + } +} +---- + +==== 3. Implement AuthorizedResource + +Implement the `AuthorizedResource` trait, which defines how to load roles and handle authorization errors: + +[source,rust] +---- +impl AuthorizedResource for Inventory { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // Load roles from the Fleet since there are no roles on Inventory itself + load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed() + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + // For static resources, just return the error as-is + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} +---- + +==== 4. Define Polar Policy + +In `nexus/auth/src/authz/omicron.polar`, define the resource and its permissions: + +[source,polar] +---- +# Describes the policy for reading and modifying low-level inventory +resource Inventory { + permissions = [ "read", "modify" ]; + relations = { parent_fleet: Fleet }; + "read" if "viewer" on "parent_fleet"; + "modify" if "admin" on "parent_fleet"; +} +has_relation(fleet: Fleet, "parent_fleet", inventory: Inventory) + if inventory.fleet = fleet; +---- + +This policy says: + +* `Inventory` has two permissions: `read` and `modify` +* It has a relationship to a `Fleet` (its parent) +* The `read` permission is granted to anyone with the `viewer` role on the parent Fleet +* The `modify` permission is granted to anyone with the `admin` role on the parent Fleet + +==== 5. Register with Oso + +Add your type to the `classes` array in `nexus/auth/src/authz/oso_generic.rs`, in the `make_omicron_oso()` function: + +[source,rust] +---- +pub fn make_omicron_oso(log: &slog::Logger) -> Result { + let mut oso_builder = OsoInitBuilder::new(log.clone()); + let classes = [ + // ... existing classes ... + Inventory::get_polar_class(), + // ... more classes ... + ]; + for c in classes { + oso_builder = oso_builder.register_class(c)?; + } + // ... +} +---- + +==== 6. Add to Policy Tests + +In `nexus/db-queries/src/policy_test/resources.rs`, add your resource to the `make_resources()` function: + +[source,rust] +---- +pub async fn make_resources( + mut builder: ResourceBuilder<'_>, + main_silo_id: Uuid, +) -> ResourceSet { + // Global resources + builder.new_resource(authz::INVENTORY); + // ... +} +---- + +=== Example: Inventory + +See `nexus/auth/src/authz/api_resources.rs:641-682` for the complete `Inventory` implementation, and `nexus/auth/src/authz/omicron.polar:462-469` for its Polar policy. + +== Dynamic Top-Level Resources + +Dynamic top-level resources are those where you can have multiple instances at the top of the hierarchy. The primary example is `Silo`. + +=== Characteristics + +* Multiple instances can exist +* Usually support role assignments +* Identified by a UUID or unique key +* Typically children of Fleet in the hierarchy + +=== Implementation Steps + +==== 1. Use the `authz_resource!` Macro + +For dynamic resources with database backing, use the `authz_resource!` macro. This generates most of the boilerplate: + +[source,rust] +---- +authz_resource! { + name = "Silo", + parent = "Fleet", + primary_key = Uuid, + roles_allowed = true, + polar_snippet = Custom, +} +---- + +Parameters: + +* `name`: Name of the resource type (must match a `ResourceType` enum variant) +* `parent`: Name of the parent authz resource type +* `primary_key`: Rust type for the resource's unique identifier (typically `Uuid`) +* `roles_allowed`: Whether users can assign roles directly to this resource +* `polar_snippet`: How to generate the Polar policy (see below) + +==== 2. Polar Snippet Options + +The `polar_snippet` parameter determines how Polar policy is generated: + +* `Custom`: You write the entire Polar policy manually in `omicron.polar` +* `FleetChild`: Auto-generated policy for fleet-level resources requiring admin privileges +* `InSilo`: Auto-generated policy for Silo-scoped resources +* `InProjectLimited`: Auto-generated policy for Project resources accessible to `limited-collaborator` +* `InProjectFull`: Auto-generated policy for Project resources requiring full `collaborator` role + +For top-level resources that support custom role assignments (like Silo), use `Custom` and write the policy manually. + +==== 3. Implement ApiResourceWithRolesType + +If `roles_allowed = true`, implement `ApiResourceWithRolesType` to specify which roles are allowed: + +[source,rust] +---- +impl ApiResourceWithRolesType for Silo { + type AllowedRoles = SiloRole; +} +---- + +The `AllowedRoles` type should be an enum implementing `serde::Serialize`, `serde::de::DeserializeOwned`, and `nexus_db_model::DatabaseString`. + +==== 4. Define Custom Polar Policy + +In `nexus/auth/src/authz/omicron.polar`, define the resource structure, permissions, roles, and relationships: + +[source,polar] +---- +resource Silo { + permissions = [ + "list_children", + "modify", + "read", + "create_child", + ]; + roles = [ "admin", "collaborator", "limited-collaborator", "viewer" ]; + + # Roles implied by other roles on this resource + "viewer" if "limited-collaborator"; + "limited-collaborator" if "collaborator"; + "collaborator" if "admin"; + + # Permissions granted directly by roles on this resource + "list_children" if "viewer"; + "read" if "viewer"; + "create_child" if "collaborator"; + "modify" if "admin"; + + # Permissions from parent (Fleet) roles + relations = { parent_fleet: Fleet }; + "read" if "viewer" on "parent_fleet"; + "modify" if "collaborator" on "parent_fleet"; + "list_children" if "external-authenticator" on "parent_fleet"; + "create_child" if "external-authenticator" on "parent_fleet"; +} + +has_relation(fleet: Fleet, "parent_fleet", silo: Silo) + if silo.fleet = fleet; +---- + +==== 5. Register with Oso + +Add your type to the `generated_inits` array in `make_omicron_oso()`: + +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + Silo::init(), + // ... more resources ... +]; + +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} +---- + +==== 6. Add to Policy Tests + +Add representative instances of your resource to `make_resources()`: + +[source,rust] +---- +make_silo(&mut builder, "silo1", main_silo_id, true).await; +make_silo(&mut builder, "silo2", Uuid::new_v4(), false).await; +---- + +Create a helper function if your resource has a complex hierarchy beneath it. + +=== Example: Silo + +See `nexus/auth/src/authz/api_resources.rs:1563-1573` for the Silo definition and `nexus/auth/src/authz/omicron.polar:123-157` for its Polar policy. + +== Static Child Resources + +Static child resources are synthetic collection resources that exist conceptually but aren't stored as distinct entities in the database. Examples include `SiloCertificateList` (the collection of certificates for a Silo) and `VpcList` (the collection of VPCs in a Project). + +=== Characteristics + +* Represents a collection under a parent resource +* Not stored directly in the database +* Used to control permissions for creating child resources +* Each parent resource has exactly one instance of the collection + +=== Implementation Steps + +==== 1. Define the Rust Type + +Create a struct that wraps the parent resource: + +[source,rust] +---- +/// Synthetic resource describing the list of Certificates associated with a +/// Silo +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SiloCertificateList(Silo); + +impl SiloCertificateList { + pub fn new(silo: Silo) -> SiloCertificateList { + SiloCertificateList(silo) + } + + pub fn silo(&self) -> &Silo { + &self.0 + } +} +---- + +==== 2. Implement PolarClass + +Add an attribute getter that returns the parent: + +[source,rust] +---- +impl oso::PolarClass for SiloCertificateList { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_attribute_getter("silo", |list: &SiloCertificateList| { + list.0.clone() + }) + } +} +---- + +==== 3. Implement AuthorizedResource + +Delegate role loading to the parent: + +[source,rust] +---- +impl AuthorizedResource for SiloCertificateList { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // There are no roles on this resource, but we still need to load the + // Silo-related roles. + self.silo().load_roles(opctx, authn, roleset) + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} +---- + +==== 4. Define Polar Policy + +Define permissions for the collection, typically based on parent roles: + +[source,polar] +---- +# Describes the policy for creating and managing Silo certificates +resource SiloCertificateList { + permissions = [ "list_children", "create_child" ]; + + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # Both Fleet and Silo administrators can see and modify the Silo's + # certificates. + "list_children" if "admin" on "parent_silo"; + "list_children" if "admin" on "parent_fleet"; + "create_child" if "admin" on "parent_silo"; + "create_child" if "admin" on "parent_fleet"; +} + +has_relation(silo: Silo, "parent_silo", collection: SiloCertificateList) + if collection.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", collection: SiloCertificateList) + if collection.silo.fleet = fleet; +---- + +Note that collection resources often define relationships to multiple ancestors (both `parent_silo` and `parent_fleet` in this example). + +==== 5. Register with Oso + +Add to the `classes` array in `make_omicron_oso()`: + +[source,rust] +---- +let classes = [ + // ... existing classes ... + SiloCertificateList::get_polar_class(), + // ... more classes ... +]; +---- + +==== 6. Add to Policy Tests + +Instantiate the collection in the parent resource's test helper: + +[source,rust] +---- +async fn make_silo( + builder: &mut ResourceBuilder<'_>, + silo_name: &str, + silo_id: Uuid, + first_branch: bool, +) { + let silo = authz::Silo::new(/* ... */); + // ... + builder.new_resource(authz::SiloCertificateList::new(silo.clone())); + // ... +} +---- + +=== Example: SiloCertificateList + +See `nexus/auth/src/authz/api_resources.rs:684-734` for the complete implementation and `nexus/auth/src/authz/omicron.polar:614-630` for the Polar policy. + +=== Example: VpcList + +`VpcList` is an interesting case because it enforces different permissions than its parent Project. While most Project resources can be created by users with the `limited-collaborator` role, creating VPCs requires the full `collaborator` role. This allows organizations to restrict who can reconfigure network topology. + +See `nexus/auth/src/authz/api_resources.rs:998-1045` and `nexus/auth/src/authz/omicron.polar:848-863`. + +== Dynamic Nested Resources + +Dynamic nested resources are the most common type: resources with multiple instances nested under other dynamic resources. Examples include Project (under Silo), Instance (under Project), and VpcSubnet (under Vpc). + +=== Characteristics + +* Multiple instances can exist under each parent +* Identified by a UUID or unique key +* May be nested multiple levels deep +* Usually don't support direct role assignments (roles come from ancestor resources) + +=== Implementation Steps + +==== 1. Use the `authz_resource!` Macro + +The `authz_resource!` macro handles most of the work: + +[source,rust] +---- +authz_resource! { + name = "Disk", + parent = "Project", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectLimited, +} +---- + +For resources directly under Project, choose between: + +* `InProjectLimited`: For compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` role can create and modify these. +* `InProjectFull`: For networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role to create or modify. + +The distinction allows organizations to give users access to compute resources while restricting who can reconfigure networking. + +==== 2. Resources Nested Deeper + +For resources nested under something other than Project (e.g., VpcSubnet under Vpc), use the same Polar snippet as the parent. The macro will automatically generate the appropriate policy that traces back to the containing Project: + +[source,rust] +---- +authz_resource! { + name = "VpcSubnet", + parent = "Vpc", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectFull, // Same as Vpc +} +---- + +==== 3. Resources Under Silo + +For resources directly under Silo, use `InSilo`: + +[source,rust] +---- +authz_resource! { + name = "SiloImage", + parent = "Silo", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InSilo, +} +---- + +This grants permissions based on Silo roles: `viewer` can read, `collaborator` can create/modify. + +==== 4. Resources Under Fleet + +For fleet-level resources requiring admin access, use `FleetChild`: + +[source,rust] +---- +authz_resource! { + name = "Rack", + parent = "Fleet", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = FleetChild, +} +---- + +This grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. + +==== 5. Custom Nested Resources + +Some nested resources need custom Polar policy because they have unusual permission requirements. Use `polar_snippet = Custom` and write the policy manually in `omicron.polar`. + +Example: `SshKey` (under `SiloUser`) has custom policy because users can manage their own SSH keys, but SCIM IdP tokens cannot. + +==== 6. Register with Oso + +Add to the `generated_inits` array in `make_omicron_oso()`: + +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + Disk::init(), + VpcSubnet::init(), + // ... more resources ... +]; +---- + +==== 7. Add to Policy Tests + +Add instances in the appropriate parent resource's test helper: + +[source,rust] +---- +async fn make_project( + builder: &mut ResourceBuilder<'_>, + silo: &authz::Silo, + project_name: &str, + first_branch: bool, +) { + let project = authz::Project::new(/* ... */); + // ... + + let disk_name = format!("{}-disk1", project_name); + builder.new_resource(authz::Disk::new( + project.clone(), + Uuid::new_v4(), + LookupType::ByName(disk_name.clone()), + )); + + // ... +} +---- + +=== Example: Disk (directly under Project) + +[source,rust] +---- +authz_resource! { + name = "Disk", + parent = "Project", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectLimited, +} +---- + +This generates Polar policy granting permissions based on Project roles, where `limited-collaborator` is sufficient to manage disks. + +See `nexus/auth/src/authz/api_resources.rs:1300-1306` for the definition. The generated Polar policy is visible in the macro implementation at `nexus/authz-macros/src/lib.rs:394-415`. + +=== Example: VpcSubnet (nested two levels deep) + +[source,rust] +---- +authz_resource! { + name = "VpcSubnet", + parent = "Vpc", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectFull, +} +---- + +This generates Polar policy that establishes both a `parent` relationship (to Vpc) and a `containing_project` relationship (to Project). Permissions are based on Project roles, requiring full `collaborator`. + +See `nexus/auth/src/authz/api_resources.rs:1396-1402`. + +=== Example: InstanceNetworkInterface (nested under Instance) + +[source,rust] +---- +authz_resource! { + name = "InstanceNetworkInterface", + parent = "Instance", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectLimited, +} +---- + +Even though this is nested under Instance (which is under Project), the macro handles tracing back to the Project automatically. + +See `nexus/auth/src/authz/api_resources.rs:1348-1354`. + +== HTTP, App, and Datastore Layers + +Once you've defined the authz types and policies, you need to integrate them into the request flow through three layers. + +=== HTTP Layer + +At the HTTP layer (in `nexus/src/external_api/http_entrypoints.rs` or similar), endpoints accept raw identifiers from users: + +* UUID for resources identified by ID +* String name for resources identified by name +* Both for resources that can be looked up either way + +[source,rust] +---- +#[endpoint { + method = GET, + path = "/v1/disks/{disk}", +}] +async fn disk_view( + rqctx: RequestContext>, + path_params: Path, // Contains name or ID +) -> Result, HttpError> { + // ... +} +---- + +=== App Layer: LookupPath + +At the application layer (in `nexus/src/app/` modules), use `LookupPath` to convert raw identifiers into authz types. `LookupPath` provides a fluent API for traversing the resource hierarchy. + +[source,rust] +---- +use nexus_db_queries::db::lookup::LookupPath; + +// Start from the OpContext, which knows about the authenticated user +let (.., authz_disk) = LookupPath::new(&opctx, &datastore) + .disk_id(disk_id) // or .disk_name(name) for name-based lookup + .fetch() // Performs the database query + .await?; // authz_disk is an authz::Disk +---- + +For nested resources, build the path step by step: + +[source,rust] +---- +let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) + .project_id(project_id) + .vpc_id(vpc_id) + .vpc_subnet_id(subnet_id) + .fetch() + .await?; +---- + +Or, if you already have the parent authz type: + +[source,rust] +---- +let (.., authz_vpc) = LookupPath::new(&opctx, &datastore) + .vpc_id(vpc_id) + .fetch() + .await?; + +// Later, use the parent to look up a child +let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) + .vpc_subnet_id(subnet_id) + .fetch_for(authz_vpc.lookup_type()) + .await?; +---- + +==== Synthetic Resources + +For synthetic resources (like `SiloCertificateList`), construct them manually from their parent: + +[source,rust] +---- +let (.., authz_silo) = LookupPath::new(&opctx, &datastore) + .silo_id(silo_id) + .fetch() + .await?; + +let authz_cert_list = authz::SiloCertificateList::new(authz_silo); +---- + +=== Performing Authorization Checks + +Once you have the authz type, authorize the action before proceeding: + +[source,rust] +---- +// Authorize reading the disk +opctx.authorize(authz::Action::Read, &authz_disk).await?; + +// Now it's safe to fetch the disk data from the datastore +let disk = datastore.disk_fetch(&opctx, &authz_disk).await?; +---- + +Common actions: + +* `Action::Read`: Read a resource +* `Action::Modify` / `Action::Delete`: Modify or delete a resource +* `Action::ListChildren`: List child resources +* `Action::CreateChild`: Create a child resource + +=== Datastore Layer + +Datastore functions (in `nexus/db-queries/src/db/datastore/`) accept authz types directly rather than raw UUIDs. This ensures: + +1. The resource has been looked up (and exists) +2. Basic authz checks have been done (the caller can at least see it exists) +3. The datastore can do additional authz checks if needed + +[source,rust] +---- +impl DataStore { + pub async fn disk_fetch( + &self, + opctx: &OpContext, + authz_disk: &authz::Disk, // Takes authz type, not UUID + ) -> Result { + // Can use authz_disk.id() to get the UUID if needed + let disk_id = authz_disk.id(); + // ... query database ... + } + + pub async fn disk_update( + &self, + opctx: &OpContext, + authz_disk: &authz::Disk, + updates: DiskUpdate, + ) -> Result { + // Might do additional authz checks here + opctx.authorize(authz::Action::Modify, authz_disk).await?; + // ... update database ... + } +} +---- + +For operations that create resources, the datastore function typically accepts the parent's authz type: + +[source,rust] +---- +impl DataStore { + pub async fn disk_create( + &self, + opctx: &OpContext, + authz_project: &authz::Project, // Parent resource + disk: db::model::Disk, + ) -> Result { + // Verify the user can create children of the project + opctx.authorize(authz::Action::CreateChild, authz_project).await?; + // ... insert into database ... + } +} +---- + +== Complete Example: Adding a New Resource + +Let's walk through adding a hypothetical `DiskSnapshot` resource that lives under `Disk`. + +=== Step 1: Determine the Category + +`DiskSnapshot` is a dynamic nested resource: multiple instances under each Disk, which is under Project. It's a compute resource (not networking), so users with `limited-collaborator` should be able to create them. + +=== Step 2: Define with `authz_resource!` + +In `nexus/auth/src/authz/api_resources.rs`: + +[source,rust] +---- +authz_resource! { + name = "DiskSnapshot", + parent = "Disk", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectLimited, +} +---- + +=== Step 3: Register with Oso + +In `nexus/auth/src/authz/oso_generic.rs`, add to `generated_inits`: + +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + DiskSnapshot::init(), + // ... more resources ... +]; +---- + +=== Step 4: Add to Policy Tests + +In `nexus/db-queries/src/policy_test/resources.rs`, add to `make_project()`: + +[source,rust] +---- +async fn make_project( + builder: &mut ResourceBuilder<'_>, + silo: &authz::Silo, + project_name: &str, + first_branch: bool, +) { + // ... existing code ... + + let disk_name = format!("{}-disk1", project_name); + let disk = authz::Disk::new( + project.clone(), + Uuid::new_v4(), + LookupType::ByName(disk_name.clone()), + ); + builder.new_resource(disk.clone()); + + // Add disk snapshot + let snapshot_name = format!("{}-snapshot1", disk_name); + builder.new_resource(authz::DiskSnapshot::new( + disk.clone(), + Uuid::new_v4(), + LookupType::ByName(snapshot_name), + )); + + // ... +} +---- + +=== Step 5: Add LookupPath Support + +In `nexus/db-queries/src/db/lookup.rs`, add methods to look up disk snapshots: + +[source,rust] +---- +impl<'a> LookupPath<'a> { + pub fn disk_snapshot_id( + self, + id: Uuid, + ) -> LookupPath<'a> { + // Implementation to look up by ID + } + + pub fn disk_snapshot_name( + self, + name: &Name, + ) -> LookupPath<'a> { + // Implementation to look up by name + } +} +---- + +=== Step 6: Implement HTTP Endpoints + +In `nexus/src/external_api/http_entrypoints.rs`: + +[source,rust] +---- +#[endpoint { + method = GET, + path = "/v1/disks/{disk}/snapshots/{snapshot}", +}] +async fn disk_snapshot_view( + rqctx: RequestContext>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + + // Look up the disk snapshot using LookupPath + let (.., authz_snapshot) = LookupPath::new(&opctx, &nexus.datastore()) + .disk_id(path_params.into_inner().disk_id) + .disk_snapshot_id(path_params.into_inner().snapshot_id) + .fetch() + .await?; + + // Authorize the read + opctx.authorize(authz::Action::Read, &authz_snapshot).await?; + + // Fetch from datastore + let snapshot = nexus.disk_snapshot_fetch(&opctx, &authz_snapshot).await?; + + Ok(HttpResponseOk(snapshot.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} +---- + +=== Step 7: Implement Datastore Methods + +In `nexus/db-queries/src/db/datastore/disk.rs`: + +[source,rust] +---- +impl DataStore { + pub async fn disk_snapshot_fetch( + &self, + opctx: &OpContext, + authz_snapshot: &authz::DiskSnapshot, + ) -> Result { + let snapshot_id = authz_snapshot.id(); + // ... database query ... + } + + pub async fn disk_snapshot_create( + &self, + opctx: &OpContext, + authz_disk: &authz::Disk, + snapshot: db::model::DiskSnapshot, + ) -> Result { + opctx.authorize(authz::Action::CreateChild, authz_disk).await?; + // ... database insert ... + } + + pub async fn disk_snapshot_delete( + &self, + opctx: &OpContext, + authz_snapshot: &authz::DiskSnapshot, + ) -> Result<(), Error> { + opctx.authorize(authz::Action::Delete, authz_snapshot).await?; + // ... database delete ... + } +} +---- + +== Common Patterns and Tips + +=== When to Use Custom Polar Policy + +Use `polar_snippet = Custom` when: + +* The resource has unusual permission requirements not covered by the standard snippets +* Multiple ancestor resources should grant permissions (e.g., both Fleet and Silo admins) +* Permissions depend on custom logic or resource-specific attributes +* The resource needs special actor-specific rules (e.g., users can modify their own SSH keys) + +=== Multiple Relations in Polar + +Some resources define multiple relationships in their Polar policy. For example, `Certificate` has relationships to both its Silo and the Fleet: + +[source,polar] +---- +resource Certificate { + permissions = [ "read", "modify" ]; + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # Both levels grant permissions + "read" if "admin" on "parent_silo"; + "modify" if "admin" on "parent_silo"; + "read" if "admin" on "parent_fleet"; + "modify" if "admin" on "parent_fleet"; +} +---- + +This allows both Silo admins and Fleet admins to manage certificates. + +=== Typed UUIDs + +Modern resources should use typed UUIDs from the `omicron_uuid_kinds` crate for type safety: + +[source,rust] +---- +authz_resource! { + name = "SiloUser", + parent = "Silo", + primary_key = { uuid_kind = SiloUserKind }, + roles_allowed = false, + polar_snippet = Custom, +} +---- + +This provides compile-time type checking that prevents mixing up IDs from different resource types. + +=== Testing Your Implementation + +After implementing authz for a resource: + +1. **Run the policy test**: `cargo nextest run -p omicron-nexus policy` + - This verifies that all authz types are registered and tested + - It exhaustively tests permission checks for all roles and resources + +2. **Run integration tests**: Create integration tests for your endpoints + - Test that unauthorized users get 403/404 errors + - Test that authorized users can perform allowed operations + - Test that users can't exceed their permissions + +3. **Check with clippy**: `cargo xtask clippy` + +4. **Format code**: `rustfmt` (wrapping at 80 columns) + +=== Error Handling: 404 vs 403 + +The authz system automatically converts 403 errors to 404 when appropriate: + +* If a user tries an unauthorized action on a resource they can't even see, they get 404 (resource not found) +* If a user tries an unauthorized action on a resource they can see, they get 403 (forbidden) + +This is handled by the `on_unauthorized` method in the `AuthorizedResource` trait, using the stored `lookup_type` to generate an appropriate "not found" error. + +== Key Files Reference + +* `nexus/auth/src/authz/mod.rs`: Overview of the authz subsystem +* `nexus/auth/src/authz/api_resources.rs`: Authz type definitions +* `nexus/auth/src/authz/omicron.polar`: Polar policy rules +* `nexus/auth/src/authz/oso_generic.rs`: Oso initialization and registration +* `nexus/authz-macros/src/lib.rs`: `authz_resource!` macro implementation +* `nexus/db-queries/src/policy_test/resources.rs`: Policy test resource setup +* `nexus/db-queries/src/db/lookup.rs`: LookupPath implementation + +== Summary + +To add authz for a new resource: + +1. **Identify the category**: Static top-level, dynamic top-level, static child, or dynamic nested +2. **Define the Rust type**: Use `authz_resource!` macro for most resources, or hand-write for special cases +3. **Choose or write Polar policy**: Use standard snippets (`FleetChild`, `InSilo`, `InProjectLimited`, `InProjectFull`) or write custom policy +4. **Register with Oso**: Add to `make_omicron_oso()` in `oso_generic.rs` +5. **Add to policy tests**: Add to `make_resources()` in `policy_test/resources.rs` +6. **Integrate into request flow**: + - HTTP layer: Accept raw identifiers + - App layer: Use `LookupPath` to get authz types + - Datastore layer: Accept authz types in function signatures +7. **Test thoroughly**: Run policy tests and write integration tests + +The authz system provides compile-time and runtime guarantees that every operation is properly authorized, helping prevent security vulnerabilities. From 1aed2909a9c57b7c47f6a9e922a69099d513a748 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 16:41:26 -0800 Subject: [PATCH 02/12] claude: draft 2 --- docs/adding-authz.adoc | 845 ++++++++--------------------------------- 1 file changed, 156 insertions(+), 689 deletions(-) diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc index c2bca6b6e99..949fcf8f477 100644 --- a/docs/adding-authz.adoc +++ b/docs/adding-authz.adoc @@ -4,37 +4,19 @@ == Overview -This document explains how to add authorization (authz) support for new resources in Omicron. Authorization in Omicron is based on role-based access control (RBAC) using the Oso policy engine. The implementation spans multiple layers of the codebase, from defining Rust types to specifying Polar policy rules. +This document explains how to add authorization (authz) support for new resources in Omicron. Authorization is based on role-based access control (RBAC) using the Oso policy engine. -Before implementing authz for a new resource, you should understand the basic concepts described in the module comments of `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs`. +Before implementing authz for a new resource, read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. -== Resource Categories - -Resources in Omicron fall into four main categories, each requiring a slightly different approach: - -1. **Static top-level resources** - Singleton resources at the top of the hierarchy (e.g., Fleet, system inventory) -2. **Dynamic top-level resources** - Multiple instances of a resource type at the top level (e.g., Silo) -3. **Static child resources** - Synthetic collection resources beneath a dynamic parent (e.g., Silo's certificate list) -4. **Dynamic nested resources** - Resources with multiple instances nested under other dynamic resources (e.g., Project under Silo, Instance under Project) - -The approach you take depends on which category your resource falls into. +Resources fall into four categories, each with a different implementation pattern. This document provides complete examples for each category that you can adapt for your resource. == Static Top-Level Resources -Static top-level resources are singletons representing system-wide concepts. Examples include `Fleet`, `Inventory`, and `DnsConfig`. - -=== Characteristics - -* There is only one instance in the entire system -* Typically represented as a unit struct or zero-sized type -* Usually require fleet-level admin privileges -* Do not support role assignments (roles are on the Fleet instead) +Singleton resources representing system-wide concepts. Examples: `Fleet`, `Inventory`, `DnsConfig`. -=== Implementation Steps - -==== 1. Define the Rust Type +=== Example: Inventory -In `nexus/auth/src/authz/api_resources.rs`, define your type as a unit struct and create a singleton constant: +**In nexus/auth/src/authz/api_resources.rs:** [source,rust] ---- @@ -43,21 +25,9 @@ In `nexus/auth/src/authz/api_resources.rs`, define your type as a unit struct an #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Inventory; pub const INVENTORY: Inventory = Inventory {}; ----- - -==== 2. Implement PolarClass - -Implement `oso::PolarClass` to define how Oso interacts with this type. For most static resources, you'll want to: -* Mark it with equality checking -* Add a method to check roles (usually returns `false` since roles are on the parent) -* Add an attribute getter for the parent (typically `fleet`) - -[source,rust] ----- impl oso::PolarClass for Inventory { fn get_polar_class_builder() -> oso::ClassBuilder { - // Roles are not directly attached to Inventory oso::Class::builder() .with_equality_check() .add_method( @@ -69,14 +39,7 @@ impl oso::PolarClass for Inventory { .add_attribute_getter("fleet", |_| FLEET) } } ----- - -==== 3. Implement AuthorizedResource -Implement the `AuthorizedResource` trait, which defines how to load roles and handle authorization errors: - -[source,rust] ----- impl AuthorizedResource for Inventory { fn load_roles<'fut>( &'fut self, @@ -84,7 +47,6 @@ impl AuthorizedResource for Inventory { authn: &'fut authn::Context, roleset: &'fut mut RoleSet, ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { - // Load roles from the Fleet since there are no roles on Inventory itself load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed() } @@ -95,7 +57,6 @@ impl AuthorizedResource for Inventory { _: AnyActor, _: Action, ) -> Error { - // For static resources, just return the error as-is error } @@ -105,13 +66,10 @@ impl AuthorizedResource for Inventory { } ---- -==== 4. Define Polar Policy - -In `nexus/auth/src/authz/omicron.polar`, define the resource and its permissions: +**In nexus/auth/src/authz/omicron.polar:** [source,polar] ---- -# Describes the policy for reading and modifying low-level inventory resource Inventory { permissions = [ "read", "modify" ]; relations = { parent_fleet: Fleet }; @@ -122,201 +80,73 @@ has_relation(fleet: Fleet, "parent_fleet", inventory: Inventory) if inventory.fleet = fleet; ---- -This policy says: - -* `Inventory` has two permissions: `read` and `modify` -* It has a relationship to a `Fleet` (its parent) -* The `read` permission is granted to anyone with the `viewer` role on the parent Fleet -* The `modify` permission is granted to anyone with the `admin` role on the parent Fleet - -==== 5. Register with Oso - -Add your type to the `classes` array in `nexus/auth/src/authz/oso_generic.rs`, in the `make_omicron_oso()` function: +**In nexus/auth/src/authz/oso_generic.rs,** add to the `classes` array in `make_omicron_oso()`: [source,rust] ---- -pub fn make_omicron_oso(log: &slog::Logger) -> Result { - let mut oso_builder = OsoInitBuilder::new(log.clone()); - let classes = [ - // ... existing classes ... - Inventory::get_polar_class(), - // ... more classes ... - ]; - for c in classes { - oso_builder = oso_builder.register_class(c)?; - } - // ... -} +let classes = [ + // ... existing classes ... + Inventory::get_polar_class(), +]; ---- -==== 6. Add to Policy Tests - -In `nexus/db-queries/src/policy_test/resources.rs`, add your resource to the `make_resources()` function: +**In nexus/db-queries/src/policy_test/resources.rs,** add to `make_resources()`: [source,rust] ---- -pub async fn make_resources( - mut builder: ResourceBuilder<'_>, - main_silo_id: Uuid, -) -> ResourceSet { - // Global resources - builder.new_resource(authz::INVENTORY); - // ... -} +builder.new_resource(authz::INVENTORY); ---- -=== Example: Inventory - -See `nexus/auth/src/authz/api_resources.rs:641-682` for the complete `Inventory` implementation, and `nexus/auth/src/authz/omicron.polar:462-469` for its Polar policy. - == Dynamic Top-Level Resources -Dynamic top-level resources are those where you can have multiple instances at the top of the hierarchy. The primary example is `Silo`. - -=== Characteristics +Resources where multiple instances exist at the top level. Examples: `Silo`, `IpPool`. -* Multiple instances can exist -* Usually support role assignments -* Identified by a UUID or unique key -* Typically children of Fleet in the hierarchy +=== Example: IpPool (using authz_resource! macro) -=== Implementation Steps - -==== 1. Use the `authz_resource!` Macro - -For dynamic resources with database backing, use the `authz_resource!` macro. This generates most of the boilerplate: +**In nexus/auth/src/authz/api_resources.rs:** [source,rust] ---- authz_resource! { - name = "Silo", + name = "IpPool", parent = "Fleet", primary_key = Uuid, - roles_allowed = true, - polar_snippet = Custom, -} ----- - -Parameters: - -* `name`: Name of the resource type (must match a `ResourceType` enum variant) -* `parent`: Name of the parent authz resource type -* `primary_key`: Rust type for the resource's unique identifier (typically `Uuid`) -* `roles_allowed`: Whether users can assign roles directly to this resource -* `polar_snippet`: How to generate the Polar policy (see below) - -==== 2. Polar Snippet Options - -The `polar_snippet` parameter determines how Polar policy is generated: - -* `Custom`: You write the entire Polar policy manually in `omicron.polar` -* `FleetChild`: Auto-generated policy for fleet-level resources requiring admin privileges -* `InSilo`: Auto-generated policy for Silo-scoped resources -* `InProjectLimited`: Auto-generated policy for Project resources accessible to `limited-collaborator` -* `InProjectFull`: Auto-generated policy for Project resources requiring full `collaborator` role - -For top-level resources that support custom role assignments (like Silo), use `Custom` and write the policy manually. - -==== 3. Implement ApiResourceWithRolesType - -If `roles_allowed = true`, implement `ApiResourceWithRolesType` to specify which roles are allowed: - -[source,rust] ----- -impl ApiResourceWithRolesType for Silo { - type AllowedRoles = SiloRole; -} ----- - -The `AllowedRoles` type should be an enum implementing `serde::Serialize`, `serde::de::DeserializeOwned`, and `nexus_db_model::DatabaseString`. - -==== 4. Define Custom Polar Policy - -In `nexus/auth/src/authz/omicron.polar`, define the resource structure, permissions, roles, and relationships: - -[source,polar] ----- -resource Silo { - permissions = [ - "list_children", - "modify", - "read", - "create_child", - ]; - roles = [ "admin", "collaborator", "limited-collaborator", "viewer" ]; - - # Roles implied by other roles on this resource - "viewer" if "limited-collaborator"; - "limited-collaborator" if "collaborator"; - "collaborator" if "admin"; - - # Permissions granted directly by roles on this resource - "list_children" if "viewer"; - "read" if "viewer"; - "create_child" if "collaborator"; - "modify" if "admin"; - - # Permissions from parent (Fleet) roles - relations = { parent_fleet: Fleet }; - "read" if "viewer" on "parent_fleet"; - "modify" if "collaborator" on "parent_fleet"; - "list_children" if "external-authenticator" on "parent_fleet"; - "create_child" if "external-authenticator" on "parent_fleet"; + roles_allowed = false, + polar_snippet = FleetChild, } - -has_relation(fleet: Fleet, "parent_fleet", silo: Silo) - if silo.fleet = fleet; ---- -==== 5. Register with Oso +This generates the struct definition, `PolarClass` impl, and `ApiResource` impl. The `FleetChild` snippet generates Polar policy that grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. -Add your type to the `generated_inits` array in `make_omicron_oso()`: +**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array in `make_omicron_oso()`: [source,rust] ---- let generated_inits = [ // ... existing resources ... - Silo::init(), - // ... more resources ... + IpPool::init(), ]; - -for init in generated_inits { - oso_builder = oso_builder.register_class_with_snippet(init)?; -} ---- -==== 6. Add to Policy Tests - -Add representative instances of your resource to `make_resources()`: - -[source,rust] ----- -make_silo(&mut builder, "silo1", main_silo_id, true).await; -make_silo(&mut builder, "silo2", Uuid::new_v4(), false).await; ----- +**In nexus/db-queries/src/policy_test/resources.rs,** add instances to test (typically in a helper function or directly in `make_resources()`). -Create a helper function if your resource has a complex hierarchy beneath it. +=== Polar Snippet Options -=== Example: Silo +The `polar_snippet` parameter controls what Polar policy is generated: -See `nexus/auth/src/authz/api_resources.rs:1563-1573` for the Silo definition and `nexus/auth/src/authz/omicron.polar:123-157` for its Polar policy. +* `FleetChild`: For fleet-level resources. Grants viewer/admin permissions based on Fleet roles. +* `InSilo`: For Silo-scoped resources. Grants permissions based on Silo roles. +* `InProjectLimited`: For Project resources accessible to `limited-collaborator` (compute resources like instances, disks). +* `InProjectFull`: For Project resources requiring full `collaborator` role (networking resources like VPCs, routers). +* `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. See `Silo`, `Project`, `SshKey`, or `Certificate` for examples of custom policies. == Static Child Resources -Static child resources are synthetic collection resources that exist conceptually but aren't stored as distinct entities in the database. Examples include `SiloCertificateList` (the collection of certificates for a Silo) and `VpcList` (the collection of VPCs in a Project). - -=== Characteristics - -* Represents a collection under a parent resource -* Not stored directly in the database -* Used to control permissions for creating child resources -* Each parent resource has exactly one instance of the collection - -=== Implementation Steps +Synthetic collection resources that don't exist as separate database entities. Examples: `SiloCertificateList`, `VpcList`. -==== 1. Define the Rust Type +=== Example: SiloCertificateList -Create a struct that wraps the parent resource: +**In nexus/auth/src/authz/api_resources.rs:** [source,rust] ---- @@ -334,14 +164,7 @@ impl SiloCertificateList { &self.0 } } ----- - -==== 2. Implement PolarClass -Add an attribute getter that returns the parent: - -[source,rust] ----- impl oso::PolarClass for SiloCertificateList { fn get_polar_class_builder() -> oso::ClassBuilder { oso::Class::builder() @@ -351,14 +174,7 @@ impl oso::PolarClass for SiloCertificateList { }) } } ----- - -==== 3. Implement AuthorizedResource -Delegate role loading to the parent: - -[source,rust] ----- impl AuthorizedResource for SiloCertificateList { fn load_roles<'fut>( &'fut self, @@ -366,8 +182,6 @@ impl AuthorizedResource for SiloCertificateList { authn: &'fut authn::Context, roleset: &'fut mut RoleSet, ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { - // There are no roles on this resource, but we still need to load the - // Silo-related roles. self.silo().load_roles(opctx, authn, roleset) } @@ -387,20 +201,15 @@ impl AuthorizedResource for SiloCertificateList { } ---- -==== 4. Define Polar Policy - -Define permissions for the collection, typically based on parent roles: +**In nexus/auth/src/authz/omicron.polar:** [source,polar] ---- -# Describes the policy for creating and managing Silo certificates resource SiloCertificateList { permissions = [ "list_children", "create_child" ]; relations = { parent_silo: Silo, parent_fleet: Fleet }; - # Both Fleet and Silo administrators can see and modify the Silo's - # certificates. "list_children" if "admin" on "parent_silo"; "list_children" if "admin" on "parent_fleet"; "create_child" if "admin" on "parent_silo"; @@ -413,66 +222,26 @@ has_relation(fleet: Fleet, "parent_fleet", collection: SiloCertificateList) if collection.silo.fleet = fleet; ---- -Note that collection resources often define relationships to multiple ancestors (both `parent_silo` and `parent_fleet` in this example). - -==== 5. Register with Oso - -Add to the `classes` array in `make_omicron_oso()`: - -[source,rust] ----- -let classes = [ - // ... existing classes ... - SiloCertificateList::get_polar_class(), - // ... more classes ... -]; ----- - -==== 6. Add to Policy Tests +**In oso_generic.rs,** add to `classes` array. -Instantiate the collection in the parent resource's test helper: +**In policy_test/resources.rs,** instantiate in the parent's helper function: [source,rust] ---- -async fn make_silo( - builder: &mut ResourceBuilder<'_>, - silo_name: &str, - silo_id: Uuid, - first_branch: bool, -) { +async fn make_silo(/* ... */) { let silo = authz::Silo::new(/* ... */); // ... builder.new_resource(authz::SiloCertificateList::new(silo.clone())); - // ... } ---- -=== Example: SiloCertificateList - -See `nexus/auth/src/authz/api_resources.rs:684-734` for the complete implementation and `nexus/auth/src/authz/omicron.polar:614-630` for the Polar policy. - -=== Example: VpcList - -`VpcList` is an interesting case because it enforces different permissions than its parent Project. While most Project resources can be created by users with the `limited-collaborator` role, creating VPCs requires the full `collaborator` role. This allows organizations to restrict who can reconfigure network topology. - -See `nexus/auth/src/authz/api_resources.rs:998-1045` and `nexus/auth/src/authz/omicron.polar:848-863`. - == Dynamic Nested Resources -Dynamic nested resources are the most common type: resources with multiple instances nested under other dynamic resources. Examples include Project (under Silo), Instance (under Project), and VpcSubnet (under Vpc). - -=== Characteristics +Resources with multiple instances nested under other resources. This is the most common case. Examples: `Disk` under `Project`, `VpcSubnet` under `Vpc`. -* Multiple instances can exist under each parent -* Identified by a UUID or unique key -* May be nested multiple levels deep -* Usually don't support direct role assignments (roles come from ancestor resources) +=== Example: Disk (under Project) -=== Implementation Steps - -==== 1. Use the `authz_resource!` Macro - -The `authz_resource!` macro handles most of the work: +**In nexus/auth/src/authz/api_resources.rs:** [source,rust] ---- @@ -485,170 +254,117 @@ authz_resource! { } ---- -For resources directly under Project, choose between: - -* `InProjectLimited`: For compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` role can create and modify these. -* `InProjectFull`: For networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role to create or modify. +The `InProjectLimited` snippet generates Polar policy granting permissions based on Project roles, where `limited-collaborator` is sufficient. -The distinction allows organizations to give users access to compute resources while restricting who can reconfigure networking. +**In oso_generic.rs,** add to `generated_inits` array. -==== 2. Resources Nested Deeper - -For resources nested under something other than Project (e.g., VpcSubnet under Vpc), use the same Polar snippet as the parent. The macro will automatically generate the appropriate policy that traces back to the containing Project: +**In policy_test/resources.rs,** add instances in the parent's helper: [source,rust] ---- -authz_resource! { - name = "VpcSubnet", - parent = "Vpc", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = InProjectFull, // Same as Vpc -} ----- - -==== 3. Resources Under Silo - -For resources directly under Silo, use `InSilo`: - -[source,rust] ----- -authz_resource! { - name = "SiloImage", - parent = "Silo", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = InSilo, +async fn make_project(/* ... */) { + let project = authz::Project::new(/* ... */); + // ... + builder.new_resource(authz::Disk::new( + project.clone(), + Uuid::new_v4(), + LookupType::ByName(format!("{}-disk1", project_name)), + )); } ---- -This grants permissions based on Silo roles: `viewer` can read, `collaborator` can create/modify. - -==== 4. Resources Under Fleet +=== Example: VpcSubnet (under Vpc, two levels below Project) -For fleet-level resources requiring admin access, use `FleetChild`: +**In nexus/auth/src/authz/api_resources.rs:** [source,rust] ---- authz_resource! { - name = "Rack", - parent = "Fleet", + name = "VpcSubnet", + parent = "Vpc", primary_key = Uuid, roles_allowed = false, - polar_snippet = FleetChild, + polar_snippet = InProjectFull, } ---- -This grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. +The `InProjectFull` snippet generates Polar policy that traces back to the containing Project, requiring full `collaborator` role. The macro handles multi-level nesting automatically. -==== 5. Custom Nested Resources +**Follow the same registration steps as Disk.** -Some nested resources need custom Polar policy because they have unusual permission requirements. Use `polar_snippet = Custom` and write the policy manually in `omicron.polar`. +=== Choosing InProjectLimited vs InProjectFull -Example: `SshKey` (under `SiloUser`) has custom policy because users can manage their own SSH keys, but SCIM IdP tokens cannot. +* `InProjectLimited`: For compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` can create and modify these. +* `InProjectFull`: For networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role. -==== 6. Register with Oso +This distinction allows organizations to give users access to compute resources while restricting who can reconfigure networking. -Add to the `generated_inits` array in `make_omicron_oso()`: +== Supporting Role Assignments -[source,rust] ----- -let generated_inits = [ - // ... existing resources ... - Disk::init(), - VpcSubnet::init(), - // ... more resources ... -]; ----- +Most resources do not support role assignments directly. Roles are typically assigned only to high-level resources like Fleet, Silo, and Project. If your resource needs to support role assignments: -==== 7. Add to Policy Tests +=== Steps -Add instances in the appropriate parent resource's test helper: +**1. Define the roles enum** in `nexus/types/src/external_api/shared.rs`: [source,rust] ---- -async fn make_project( - builder: &mut ResourceBuilder<'_>, - silo: &authz::Silo, - project_name: &str, - first_branch: bool, -) { - let project = authz::Project::new(/* ... */); - // ... - - let disk_name = format!("{}-disk1", project_name); - builder.new_resource(authz::Disk::new( - project.clone(), - Uuid::new_v4(), - LookupType::ByName(disk_name.clone()), - )); - - // ... +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Display, + EnumIter, + Eq, + Ord, + PartialEq, + PartialOrd, + Serialize, +)] +#[serde(rename_all = "snake_case")] +pub enum YourResourceRole { + Admin, + Collaborator, + Viewer, } ----- -=== Example: Disk (directly under Project) - -[source,rust] ----- -authz_resource! { - name = "Disk", - parent = "Project", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = InProjectLimited, +impl DatabaseString for YourResourceRole { + type SqlType = YourResourceRoleEnum; } ---- -This generates Polar policy granting permissions based on Project roles, where `limited-collaborator` is sufficient to manage disks. +**2. Add the SQL enum type** in `nexus/db-model/src/schema_versions.rs` (follow the pattern for existing role enums). -See `nexus/auth/src/authz/api_resources.rs:1300-1306` for the definition. The generated Polar policy is visible in the macro implementation at `nexus/authz-macros/src/lib.rs:394-415`. - -=== Example: VpcSubnet (nested two levels deep) +**3. Set `roles_allowed = true`** in your `authz_resource!` invocation: [source,rust] ---- authz_resource! { - name = "VpcSubnet", - parent = "Vpc", + name = "YourResource", + parent = "Fleet", primary_key = Uuid, - roles_allowed = false, - polar_snippet = InProjectFull, + roles_allowed = true, + polar_snippet = Custom, // Usually need custom policy for resources with roles } ---- -This generates Polar policy that establishes both a `parent` relationship (to Vpc) and a `containing_project` relationship (to Project). Permissions are based on Project roles, requiring full `collaborator`. - -See `nexus/auth/src/authz/api_resources.rs:1396-1402`. - -=== Example: InstanceNetworkInterface (nested under Instance) +**4. Implement `ApiResourceWithRolesType`:** [source,rust] ---- -authz_resource! { - name = "InstanceNetworkInterface", - parent = "Instance", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = InProjectLimited, +impl ApiResourceWithRolesType for YourResource { + type AllowedRoles = YourResourceRole; } ---- -Even though this is nested under Instance (which is under Project), the macro handles tracing back to the Project automatically. - -See `nexus/auth/src/authz/api_resources.rs:1348-1354`. - -== HTTP, App, and Datastore Layers +**5. Define custom Polar policy** in `omicron.polar` that defines the roles and their permissions. See `Project` or `Silo` for examples. -Once you've defined the authz types and policies, you need to integrate them into the request flow through three layers. +== HTTP, App, and Datastore Integration === HTTP Layer -At the HTTP layer (in `nexus/src/external_api/http_entrypoints.rs` or similar), endpoints accept raw identifiers from users: - -* UUID for resources identified by ID -* String name for resources identified by name -* Both for resources that can be looked up either way +Accept raw identifiers (UUID or name) from the user: [source,rust] ---- @@ -658,7 +374,7 @@ At the HTTP layer (in `nexus/src/external_api/http_entrypoints.rs` or similar), }] async fn disk_view( rqctx: RequestContext>, - path_params: Path, // Contains name or ID + path_params: Path, ) -> Result, HttpError> { // ... } @@ -666,88 +382,49 @@ async fn disk_view( === App Layer: LookupPath -At the application layer (in `nexus/src/app/` modules), use `LookupPath` to convert raw identifiers into authz types. `LookupPath` provides a fluent API for traversing the resource hierarchy. +Use `LookupPath` to convert raw identifiers into authz types: [source,rust] ---- use nexus_db_queries::db::lookup::LookupPath; -// Start from the OpContext, which knows about the authenticated user +// Simple lookup let (.., authz_disk) = LookupPath::new(&opctx, &datastore) - .disk_id(disk_id) // or .disk_name(name) for name-based lookup - .fetch() // Performs the database query - .await?; // authz_disk is an authz::Disk ----- - -For nested resources, build the path step by step: + .disk_id(disk_id) + .fetch() + .await?; -[source,rust] ----- +// Nested resource lookup let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) .project_id(project_id) .vpc_id(vpc_id) .vpc_subnet_id(subnet_id) .fetch() .await?; ----- - -Or, if you already have the parent authz type: - -[source,rust] ----- -let (.., authz_vpc) = LookupPath::new(&opctx, &datastore) - .vpc_id(vpc_id) - .fetch() - .await?; - -// Later, use the parent to look up a child -let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) - .vpc_subnet_id(subnet_id) - .fetch_for(authz_vpc.lookup_type()) - .await?; ----- - -==== Synthetic Resources - -For synthetic resources (like `SiloCertificateList`), construct them manually from their parent: -[source,rust] ----- +// Synthetic resource - construct manually let (.., authz_silo) = LookupPath::new(&opctx, &datastore) .silo_id(silo_id) .fetch() .await?; - let authz_cert_list = authz::SiloCertificateList::new(authz_silo); ---- -=== Performing Authorization Checks +=== Performing Authorization -Once you have the authz type, authorize the action before proceeding: +Once you have the authz type, check authorization before proceeding: [source,rust] ---- -// Authorize reading the disk opctx.authorize(authz::Action::Read, &authz_disk).await?; - -// Now it's safe to fetch the disk data from the datastore let disk = datastore.disk_fetch(&opctx, &authz_disk).await?; ---- -Common actions: - -* `Action::Read`: Read a resource -* `Action::Modify` / `Action::Delete`: Modify or delete a resource -* `Action::ListChildren`: List child resources -* `Action::CreateChild`: Create a child resource +Common actions: `Read`, `Modify`, `Delete`, `ListChildren`, `CreateChild`. === Datastore Layer -Datastore functions (in `nexus/db-queries/src/db/datastore/`) accept authz types directly rather than raw UUIDs. This ensures: - -1. The resource has been looked up (and exists) -2. Basic authz checks have been done (the caller can at least see it exists) -3. The datastore can do additional authz checks if needed +Datastore functions accept authz types, not raw UUIDs. This ensures the resource has been looked up and basic authz checks have been done: [source,rust] ---- @@ -755,309 +432,99 @@ impl DataStore { pub async fn disk_fetch( &self, opctx: &OpContext, - authz_disk: &authz::Disk, // Takes authz type, not UUID + authz_disk: &authz::Disk, ) -> Result { - // Can use authz_disk.id() to get the UUID if needed let disk_id = authz_disk.id(); // ... query database ... } - pub async fn disk_update( - &self, - opctx: &OpContext, - authz_disk: &authz::Disk, - updates: DiskUpdate, - ) -> Result { - // Might do additional authz checks here - opctx.authorize(authz::Action::Modify, authz_disk).await?; - // ... update database ... - } -} ----- - -For operations that create resources, the datastore function typically accepts the parent's authz type: - -[source,rust] ----- -impl DataStore { pub async fn disk_create( &self, opctx: &OpContext, authz_project: &authz::Project, // Parent resource disk: db::model::Disk, ) -> Result { - // Verify the user can create children of the project opctx.authorize(authz::Action::CreateChild, authz_project).await?; // ... insert into database ... } } ---- -== Complete Example: Adding a New Resource - -Let's walk through adding a hypothetical `DiskSnapshot` resource that lives under `Disk`. +== Primary Key Variants -=== Step 1: Determine the Category +Most resources use `Uuid` as their primary key. Some variations: -`DiskSnapshot` is a dynamic nested resource: multiple instances under each Disk, which is under Project. It's a compute resource (not networking), so users with `limited-collaborator` should be able to create them. - -=== Step 2: Define with `authz_resource!` +=== Typed UUIDs -In `nexus/auth/src/authz/api_resources.rs`: +Use typed UUIDs for type safety: [source,rust] ---- authz_resource! { - name = "DiskSnapshot", - parent = "Disk", - primary_key = Uuid, + name = "SiloUser", + parent = "Silo", + primary_key = { uuid_kind = SiloUserKind }, roles_allowed = false, - polar_snippet = InProjectLimited, -} ----- - -=== Step 3: Register with Oso - -In `nexus/auth/src/authz/oso_generic.rs`, add to `generated_inits`: - -[source,rust] ----- -let generated_inits = [ - // ... existing resources ... - DiskSnapshot::init(), - // ... more resources ... -]; ----- - -=== Step 4: Add to Policy Tests - -In `nexus/db-queries/src/policy_test/resources.rs`, add to `make_project()`: - -[source,rust] ----- -async fn make_project( - builder: &mut ResourceBuilder<'_>, - silo: &authz::Silo, - project_name: &str, - first_branch: bool, -) { - // ... existing code ... - - let disk_name = format!("{}-disk1", project_name); - let disk = authz::Disk::new( - project.clone(), - Uuid::new_v4(), - LookupType::ByName(disk_name.clone()), - ); - builder.new_resource(disk.clone()); - - // Add disk snapshot - let snapshot_name = format!("{}-snapshot1", disk_name); - builder.new_resource(authz::DiskSnapshot::new( - disk.clone(), - Uuid::new_v4(), - LookupType::ByName(snapshot_name), - )); - - // ... -} ----- - -=== Step 5: Add LookupPath Support - -In `nexus/db-queries/src/db/lookup.rs`, add methods to look up disk snapshots: - -[source,rust] ----- -impl<'a> LookupPath<'a> { - pub fn disk_snapshot_id( - self, - id: Uuid, - ) -> LookupPath<'a> { - // Implementation to look up by ID - } - - pub fn disk_snapshot_name( - self, - name: &Name, - ) -> LookupPath<'a> { - // Implementation to look up by name - } -} ----- - -=== Step 6: Implement HTTP Endpoints - -In `nexus/src/external_api/http_entrypoints.rs`: - -[source,rust] ----- -#[endpoint { - method = GET, - path = "/v1/disks/{disk}/snapshots/{snapshot}", -}] -async fn disk_snapshot_view( - rqctx: RequestContext>, - path_params: Path, -) -> Result, HttpError> { - let apictx = rqctx.context(); - let handler = async { - let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let nexus = &apictx.nexus; - - // Look up the disk snapshot using LookupPath - let (.., authz_snapshot) = LookupPath::new(&opctx, &nexus.datastore()) - .disk_id(path_params.into_inner().disk_id) - .disk_snapshot_id(path_params.into_inner().snapshot_id) - .fetch() - .await?; - - // Authorize the read - opctx.authorize(authz::Action::Read, &authz_snapshot).await?; - - // Fetch from datastore - let snapshot = nexus.disk_snapshot_fetch(&opctx, &authz_snapshot).await?; - - Ok(HttpResponseOk(snapshot.into())) - }; - apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await + polar_snippet = Custom, } ---- -=== Step 7: Implement Datastore Methods +=== Composite Keys -In `nexus/db-queries/src/db/datastore/disk.rs`: +For resources with composite keys: [source,rust] ---- -impl DataStore { - pub async fn disk_snapshot_fetch( - &self, - opctx: &OpContext, - authz_snapshot: &authz::DiskSnapshot, - ) -> Result { - let snapshot_id = authz_snapshot.id(); - // ... database query ... - } - - pub async fn disk_snapshot_create( - &self, - opctx: &OpContext, - authz_disk: &authz::Disk, - snapshot: db::model::DiskSnapshot, - ) -> Result { - opctx.authorize(authz::Action::CreateChild, authz_disk).await?; - // ... database insert ... - } - - pub async fn disk_snapshot_delete( - &self, - opctx: &OpContext, - authz_snapshot: &authz::DiskSnapshot, - ) -> Result<(), Error> { - opctx.authorize(authz::Action::Delete, authz_snapshot).await?; - // ... database delete ... - } +authz_resource! { + name = "DeviceAuthRequest", + parent = "Fleet", + primary_key = String, // user_code + roles_allowed = false, + polar_snippet = FleetChild, } ---- -== Common Patterns and Tips - -=== When to Use Custom Polar Policy - -Use `polar_snippet = Custom` when: +== Testing -* The resource has unusual permission requirements not covered by the standard snippets -* Multiple ancestor resources should grant permissions (e.g., both Fleet and Silo admins) -* Permissions depend on custom logic or resource-specific attributes -* The resource needs special actor-specific rules (e.g., users can modify their own SSH keys) +After implementing authz: -=== Multiple Relations in Polar - -Some resources define multiple relationships in their Polar policy. For example, `Certificate` has relationships to both its Silo and the Fleet: - -[source,polar] +**1. Run the policy test:** +[source,shell] ---- -resource Certificate { - permissions = [ "read", "modify" ]; - relations = { parent_silo: Silo, parent_fleet: Fleet }; - - # Both levels grant permissions - "read" if "admin" on "parent_silo"; - "modify" if "admin" on "parent_silo"; - "read" if "admin" on "parent_fleet"; - "modify" if "admin" on "parent_fleet"; -} +cargo nextest run -p omicron-nexus policy ---- -This allows both Silo admins and Fleet admins to manage certificates. - -=== Typed UUIDs +This verifies all authz types are registered and tests permissions exhaustively. -Modern resources should use typed UUIDs from the `omicron_uuid_kinds` crate for type safety: +**2. Run integration tests** for your endpoints, verifying: +* Unauthorized users get 403/404 errors appropriately +* Authorized users can perform allowed operations +* Users can't exceed their permissions -[source,rust] +**3. Check with clippy:** +[source,shell] ---- -authz_resource! { - name = "SiloUser", - parent = "Silo", - primary_key = { uuid_kind = SiloUserKind }, - roles_allowed = false, - polar_snippet = Custom, -} +cargo xtask clippy ---- -This provides compile-time type checking that prevents mixing up IDs from different resource types. - -=== Testing Your Implementation - -After implementing authz for a resource: - -1. **Run the policy test**: `cargo nextest run -p omicron-nexus policy` - - This verifies that all authz types are registered and tested - - It exhaustively tests permission checks for all roles and resources - -2. **Run integration tests**: Create integration tests for your endpoints - - Test that unauthorized users get 403/404 errors - - Test that authorized users can perform allowed operations - - Test that users can't exceed their permissions - -3. **Check with clippy**: `cargo xtask clippy` - -4. **Format code**: `rustfmt` (wrapping at 80 columns) - -=== Error Handling: 404 vs 403 - -The authz system automatically converts 403 errors to 404 when appropriate: - -* If a user tries an unauthorized action on a resource they can't even see, they get 404 (resource not found) -* If a user tries an unauthorized action on a resource they can see, they get 403 (forbidden) - -This is handled by the `on_unauthorized` method in the `AuthorizedResource` trait, using the stored `lookup_type` to generate an appropriate "not found" error. - -== Key Files Reference +== Key Files -* `nexus/auth/src/authz/mod.rs`: Overview of the authz subsystem -* `nexus/auth/src/authz/api_resources.rs`: Authz type definitions -* `nexus/auth/src/authz/omicron.polar`: Polar policy rules -* `nexus/auth/src/authz/oso_generic.rs`: Oso initialization and registration -* `nexus/authz-macros/src/lib.rs`: `authz_resource!` macro implementation -* `nexus/db-queries/src/policy_test/resources.rs`: Policy test resource setup -* `nexus/db-queries/src/db/lookup.rs`: LookupPath implementation +* `nexus/auth/src/authz/mod.rs` - Overview of authz subsystem +* `nexus/auth/src/authz/api_resources.rs` - Authz type definitions +* `nexus/auth/src/authz/omicron.polar` - Polar policy rules +* `nexus/auth/src/authz/oso_generic.rs` - Oso initialization +* `nexus/authz-macros/src/lib.rs` - `authz_resource!` macro +* `nexus/db-queries/src/policy_test/resources.rs` - Policy test setup +* `nexus/db-queries/src/db/lookup.rs` - LookupPath implementation +* `nexus/types/src/external_api/shared.rs` - Role enum definitions == Summary To add authz for a new resource: -1. **Identify the category**: Static top-level, dynamic top-level, static child, or dynamic nested -2. **Define the Rust type**: Use `authz_resource!` macro for most resources, or hand-write for special cases -3. **Choose or write Polar policy**: Use standard snippets (`FleetChild`, `InSilo`, `InProjectLimited`, `InProjectFull`) or write custom policy -4. **Register with Oso**: Add to `make_omicron_oso()` in `oso_generic.rs` -5. **Add to policy tests**: Add to `make_resources()` in `policy_test/resources.rs` -6. **Integrate into request flow**: - - HTTP layer: Accept raw identifiers - - App layer: Use `LookupPath` to get authz types - - Datastore layer: Accept authz types in function signatures -7. **Test thoroughly**: Run policy tests and write integration tests - -The authz system provides compile-time and runtime guarantees that every operation is properly authorized, helping prevent security vulnerabilities. +1. Identify which category your resource falls into +2. Follow the example for that category +3. Register with Oso in `oso_generic.rs` +4. Add to policy tests in `policy_test/resources.rs` +5. Integrate into request flow: HTTP → LookupPath → authorize → datastore +6. Test with `cargo nextest run -p omicron-nexus policy` From c57c3a02b31905d656c513cff4cff5f3feb67043 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 16:55:48 -0800 Subject: [PATCH 03/12] claude: draft 3 --- docs/adding-authz.adoc | 218 ++++++++++++++++++++++++++++++----------- 1 file changed, 162 insertions(+), 56 deletions(-) diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc index 949fcf8f477..542af2f906d 100644 --- a/docs/adding-authz.adoc +++ b/docs/adding-authz.adoc @@ -10,9 +10,23 @@ Before implementing authz for a new resource, read the module comments in `nexus Resources fall into four categories, each with a different implementation pattern. This document provides complete examples for each category that you can adapt for your resource. +== Principles + +=== Authz Checks Should Be Close to the Action + +Perform authorization checks as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the operation they protect. For example, if you have a datastore function that modifies a resource, that function should accept the authz type and perform the authorization check, rather than expecting the caller to have done it. + +=== Avoid Baking Policy Into Authz Checks + +This is important and non-obvious. It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet." Instead, prefer creating a synthetic resource whose *policy* (in the Polar file) reflects that equivalence, while the authz check itself is straightforward: "I'm modifying the inventory, so I check authorization on the inventory resource." + +For example, rather than checking Fleet authorization when modifying inventory, we define an `Inventory` resource whose Polar policy says that Fleet admins have modify permissions on Inventory. The code just checks `authorize(Action::Modify, &INVENTORY)` without knowing anything about the Fleet. + +This separation keeps policy decisions in the Polar file where they belong, making the system easier to understand and modify. + == Static Top-Level Resources -Singleton resources representing system-wide concepts. Examples: `Fleet`, `Inventory`, `DnsConfig`. +Singleton resources representing system-wide concepts. These are almost always synthetic resources that don't correspond to database entities. Examples: `Fleet`, `Inventory`, `DnsConfig`. === Example: Inventory @@ -87,6 +101,7 @@ has_relation(fleet: Fleet, "parent_fleet", inventory: Inventory) let classes = [ // ... existing classes ... Inventory::get_polar_class(), + // ... more classes ... ]; ---- @@ -97,6 +112,16 @@ let classes = [ builder.new_resource(authz::INVENTORY); ---- +=== App Layer Usage + +For static top-level resources, use the global constant directly: + +[source,rust] +---- +// Authorize the action on the global resource +opctx.authorize(authz::Action::Modify, &authz::INVENTORY).await?; +---- + == Dynamic Top-Level Resources Resources where multiple instances exist at the top level. Examples: `Silo`, `IpPool`. @@ -125,10 +150,15 @@ This generates the struct definition, `PolarClass` impl, and `ApiResource` impl. let generated_inits = [ // ... existing resources ... IpPool::init(), + // ... more resources ... ]; + +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} ---- -**In nexus/db-queries/src/policy_test/resources.rs,** add instances to test (typically in a helper function or directly in `make_resources()`). +**In nexus/db-queries/src/policy_test/resources.rs,** add test instances (typically in a helper function or directly in `make_resources()`). === Polar Snippet Options @@ -140,9 +170,25 @@ The `polar_snippet` parameter controls what Polar policy is generated: * `InProjectFull`: For Project resources requiring full `collaborator` role (networking resources like VPCs, routers). * `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. See `Silo`, `Project`, `SshKey`, or `Certificate` for examples of custom policies. +=== App Layer Usage + +For dynamic top-level resources, use `LookupPath` to look up the resource and get its authz type: + +[source,rust] +---- +use nexus_db_queries::db::lookup::LookupPath; + +let (.., authz_ip_pool) = LookupPath::new(&opctx, &datastore) + .ip_pool_id(pool_id) + .fetch() + .await?; + +opctx.authorize(authz::Action::Read, &authz_ip_pool).await?; +---- + == Static Child Resources -Synthetic collection resources that don't exist as separate database entities. Examples: `SiloCertificateList`, `VpcList`. +Synthetic collection resources that don't exist as separate database entities. These are almost always synthetic resources. Examples: `SiloCertificateList`, `VpcList`. === Example: SiloCertificateList @@ -222,9 +268,18 @@ has_relation(fleet: Fleet, "parent_fleet", collection: SiloCertificateList) if collection.silo.fleet = fleet; ---- -**In oso_generic.rs,** add to `classes` array. +**In nexus/auth/src/authz/oso_generic.rs,** add to the `classes` array in `make_omicron_oso()`: + +[source,rust] +---- +let classes = [ + // ... existing classes ... + SiloCertificateList::get_polar_class(), + // ... more classes ... +]; +---- -**In policy_test/resources.rs,** instantiate in the parent's helper function: +**In nexus/db-queries/src/policy_test/resources.rs,** instantiate in the parent's helper function: [source,rust] ---- @@ -235,6 +290,25 @@ async fn make_silo(/* ... */) { } ---- +=== App Layer Usage + +For static child resources, construct the collection by hand after looking up the parent: + +[source,rust] +---- +use nexus_db_queries::db::lookup::LookupPath; + +let (.., authz_silo) = LookupPath::new(&opctx, &datastore) + .silo_id(silo_id) + .fetch() + .await?; + +// Construct the collection resource manually +let authz_cert_list = authz::SiloCertificateList::new(authz_silo); + +opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; +---- + == Dynamic Nested Resources Resources with multiple instances nested under other resources. This is the most common case. Examples: `Disk` under `Project`, `VpcSubnet` under `Vpc`. @@ -256,9 +330,22 @@ authz_resource! { The `InProjectLimited` snippet generates Polar policy granting permissions based on Project roles, where `limited-collaborator` is sufficient. -**In oso_generic.rs,** add to `generated_inits` array. +**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: -**In policy_test/resources.rs,** add instances in the parent's helper: +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + Disk::init(), + // ... more resources ... +]; + +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} +---- + +**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper: [source,rust] ---- @@ -290,7 +377,22 @@ authz_resource! { The `InProjectFull` snippet generates Polar policy that traces back to the containing Project, requiring full `collaborator` role. The macro handles multi-level nesting automatically. -**Follow the same registration steps as Disk.** +**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: + +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + VpcSubnet::init(), + // ... more resources ... +]; + +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} +---- + +**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper. === Choosing InProjectLimited vs InProjectFull @@ -299,6 +401,33 @@ The `InProjectFull` snippet generates Polar policy that traces back to the conta This distinction allows organizations to give users access to compute resources while restricting who can reconfigure networking. +=== App Layer Usage + +For dynamic nested resources, use `LookupPath` to traverse the hierarchy: + +[source,rust] +---- +use nexus_db_queries::db::lookup::LookupPath; + +// Simple nested lookup +let (.., authz_disk) = LookupPath::new(&opctx, &datastore) + .disk_id(disk_id) + .fetch() + .await?; + +opctx.authorize(authz::Action::Read, &authz_disk).await?; + +// Multi-level nested lookup +let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) + .project_id(project_id) + .vpc_id(vpc_id) + .vpc_subnet_id(subnet_id) + .fetch() + .await?; + +opctx.authorize(authz::Action::Modify, &authz_subnet).await?; +---- + == Supporting Role Assignments Most resources do not support role assignments directly. Roles are typically assigned only to high-level resources like Fleet, Silo, and Project. If your resource needs to support role assignments: @@ -360,7 +489,7 @@ impl ApiResourceWithRolesType for YourResource { **5. Define custom Polar policy** in `omicron.polar` that defines the roles and their permissions. See `Project` or `Silo` for examples. -== HTTP, App, and Datastore Integration +== HTTP and Datastore Integration === HTTP Layer @@ -380,39 +509,9 @@ async fn disk_view( } ---- -=== App Layer: LookupPath - -Use `LookupPath` to convert raw identifiers into authz types: - -[source,rust] ----- -use nexus_db_queries::db::lookup::LookupPath; - -// Simple lookup -let (.., authz_disk) = LookupPath::new(&opctx, &datastore) - .disk_id(disk_id) - .fetch() - .await?; - -// Nested resource lookup -let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) - .project_id(project_id) - .vpc_id(vpc_id) - .vpc_subnet_id(subnet_id) - .fetch() - .await?; - -// Synthetic resource - construct manually -let (.., authz_silo) = LookupPath::new(&opctx, &datastore) - .silo_id(silo_id) - .fetch() - .await?; -let authz_cert_list = authz::SiloCertificateList::new(authz_silo); ----- - === Performing Authorization -Once you have the authz type, check authorization before proceeding: +Once you have the authz type (from LookupPath or a global constant), check authorization before proceeding: [source,rust] ---- @@ -452,11 +551,9 @@ impl DataStore { == Primary Key Variants -Most resources use `Uuid` as their primary key. Some variations: +=== Typed UUIDs (Recommended) -=== Typed UUIDs - -Use typed UUIDs for type safety: +New code should use typed UUIDs for type safety: [source,rust] ---- @@ -469,9 +566,24 @@ authz_resource! { } ---- -=== Composite Keys +=== Plain UUID -For resources with composite keys: +For resources that haven't migrated to typed UUIDs yet: + +[source,rust] +---- +authz_resource! { + name = "Disk", + parent = "Project", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectLimited, +} +---- + +=== Other Key Types + +For resources with non-UUID keys: [source,rust] ---- @@ -488,25 +600,19 @@ authz_resource! { After implementing authz: -**1. Run the policy test:** +**Run the policy test:** [source,shell] ---- -cargo nextest run -p omicron-nexus policy +cargo nextest run -p nexus-db-queries test_policy ---- This verifies all authz types are registered and tests permissions exhaustively. -**2. Run integration tests** for your endpoints, verifying: +**Run integration tests** for your endpoints, verifying: * Unauthorized users get 403/404 errors appropriately * Authorized users can perform allowed operations * Users can't exceed their permissions -**3. Check with clippy:** -[source,shell] ----- -cargo xtask clippy ----- - == Key Files * `nexus/auth/src/authz/mod.rs` - Overview of authz subsystem @@ -527,4 +633,4 @@ To add authz for a new resource: 3. Register with Oso in `oso_generic.rs` 4. Add to policy tests in `policy_test/resources.rs` 5. Integrate into request flow: HTTP → LookupPath → authorize → datastore -6. Test with `cargo nextest run -p omicron-nexus policy` +6. Test with `cargo nextest run -p nexus-db-queries test_policy` From 6ea38b41c0bffe8dd6a1a6bd7c064d1c50d511d7 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 17:04:01 -0800 Subject: [PATCH 04/12] claude: draft 4 --- docs/adding-authz.adoc | 180 ++++++++++++++++++----------------------- 1 file changed, 77 insertions(+), 103 deletions(-) diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc index 542af2f906d..5a2cf5840c7 100644 --- a/docs/adding-authz.adoc +++ b/docs/adding-authz.adoc @@ -8,7 +8,7 @@ This document explains how to add authorization (authz) support for new resource Before implementing authz for a new resource, read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. -Resources fall into four categories, each with a different implementation pattern. This document provides complete examples for each category that you can adapt for your resource. +Resources fall into three categories, each with a different implementation pattern. This document provides complete examples for each category that you can adapt for your resource. == Principles @@ -24,11 +24,11 @@ For example, rather than checking Fleet authorization when modifying inventory, This separation keeps policy decisions in the Polar file where they belong, making the system easier to understand and modify. -== Static Top-Level Resources +== Static Resources -Singleton resources representing system-wide concepts. These are almost always synthetic resources that don't correspond to database entities. Examples: `Fleet`, `Inventory`, `DnsConfig`. +Singleton resources representing system-wide concepts or synthetic collections. These are almost always synthetic resources that don't correspond to database entities. Examples: `Fleet`, `Inventory`, `DnsConfig`, `SiloCertificateList`, `VpcList`. -=== Example: Inventory +=== Example: Inventory (static top-level resource) **In nexus/auth/src/authz/api_resources.rs:** @@ -112,85 +112,7 @@ let classes = [ builder.new_resource(authz::INVENTORY); ---- -=== App Layer Usage - -For static top-level resources, use the global constant directly: - -[source,rust] ----- -// Authorize the action on the global resource -opctx.authorize(authz::Action::Modify, &authz::INVENTORY).await?; ----- - -== Dynamic Top-Level Resources - -Resources where multiple instances exist at the top level. Examples: `Silo`, `IpPool`. - -=== Example: IpPool (using authz_resource! macro) - -**In nexus/auth/src/authz/api_resources.rs:** - -[source,rust] ----- -authz_resource! { - name = "IpPool", - parent = "Fleet", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = FleetChild, -} ----- - -This generates the struct definition, `PolarClass` impl, and `ApiResource` impl. The `FleetChild` snippet generates Polar policy that grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. - -**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array in `make_omicron_oso()`: - -[source,rust] ----- -let generated_inits = [ - // ... existing resources ... - IpPool::init(), - // ... more resources ... -]; - -for init in generated_inits { - oso_builder = oso_builder.register_class_with_snippet(init)?; -} ----- - -**In nexus/db-queries/src/policy_test/resources.rs,** add test instances (typically in a helper function or directly in `make_resources()`). - -=== Polar Snippet Options - -The `polar_snippet` parameter controls what Polar policy is generated: - -* `FleetChild`: For fleet-level resources. Grants viewer/admin permissions based on Fleet roles. -* `InSilo`: For Silo-scoped resources. Grants permissions based on Silo roles. -* `InProjectLimited`: For Project resources accessible to `limited-collaborator` (compute resources like instances, disks). -* `InProjectFull`: For Project resources requiring full `collaborator` role (networking resources like VPCs, routers). -* `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. See `Silo`, `Project`, `SshKey`, or `Certificate` for examples of custom policies. - -=== App Layer Usage - -For dynamic top-level resources, use `LookupPath` to look up the resource and get its authz type: - -[source,rust] ----- -use nexus_db_queries::db::lookup::LookupPath; - -let (.., authz_ip_pool) = LookupPath::new(&opctx, &datastore) - .ip_pool_id(pool_id) - .fetch() - .await?; - -opctx.authorize(authz::Action::Read, &authz_ip_pool).await?; ----- - -== Static Child Resources - -Synthetic collection resources that don't exist as separate database entities. These are almost always synthetic resources. Examples: `SiloCertificateList`, `VpcList`. - -=== Example: SiloCertificateList +=== Example: SiloCertificateList (static child resource) **In nexus/auth/src/authz/api_resources.rs:** @@ -292,6 +214,14 @@ async fn make_silo(/* ... */) { === App Layer Usage +For static top-level resources, use the global constant directly: + +[source,rust] +---- +// Authorize the action on the global resource +opctx.authorize(authz::Action::Modify, &authz::INVENTORY).await?; +---- + For static child resources, construct the collection by hand after looking up the parent: [source,rust] @@ -309,11 +239,45 @@ let authz_cert_list = authz::SiloCertificateList::new(authz_silo); opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; ---- -== Dynamic Nested Resources +== Dynamic Resources + +Resources with multiple instances that correspond to database entities. This is the most common case. Examples: `Silo`, `IpPool`, `Disk`, `VpcSubnet`. + +All dynamic resources use the `authz_resource!` macro. The key difference is which `polar_snippet` you choose based on where the resource lives in the hierarchy. + +=== Example: IpPool (fleet-level resource) + +**In nexus/auth/src/authz/api_resources.rs:** + +[source,rust] +---- +authz_resource! { + name = "IpPool", + parent = "Fleet", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = FleetChild, +} +---- + +**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array in `make_omicron_oso()`: -Resources with multiple instances nested under other resources. This is the most common case. Examples: `Disk` under `Project`, `VpcSubnet` under `Vpc`. +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + IpPool::init(), + // ... more resources ... +]; -=== Example: Disk (under Project) +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} +---- + +**In nexus/db-queries/src/policy_test/resources.rs,** add test instances. + +=== Example: Disk (project compute resource) **In nexus/auth/src/authz/api_resources.rs:** @@ -328,8 +292,6 @@ authz_resource! { } ---- -The `InProjectLimited` snippet generates Polar policy granting permissions based on Project roles, where `limited-collaborator` is sufficient. - **In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: [source,rust] @@ -360,7 +322,7 @@ async fn make_project(/* ... */) { } ---- -=== Example: VpcSubnet (under Vpc, two levels below Project) +=== Example: VpcSubnet (project networking resource, nested under Vpc) **In nexus/auth/src/authz/api_resources.rs:** @@ -375,8 +337,6 @@ authz_resource! { } ---- -The `InProjectFull` snippet generates Polar policy that traces back to the containing Project, requiring full `collaborator` role. The macro handles multi-level nesting automatically. - **In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: [source,rust] @@ -394,22 +354,35 @@ for init in generated_inits { **In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper. -=== Choosing InProjectLimited vs InProjectFull +=== Choosing the Right Polar Snippet -* `InProjectLimited`: For compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` can create and modify these. -* `InProjectFull`: For networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role. +The `polar_snippet` parameter controls what Polar policy is generated: + +* `FleetChild`: For fleet-level resources. Grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. +* `InSilo`: For Silo-scoped resources. Grants permissions based on Silo roles. +* `InProjectLimited`: For Project compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` can create and modify these. +* `InProjectFull`: For Project networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role. +* `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. See `Silo`, `Project`, `SshKey`, or `Certificate` for examples. -This distinction allows organizations to give users access to compute resources while restricting who can reconfigure networking. +The distinction between `InProjectLimited` and `InProjectFull` allows organizations to give users access to compute resources while restricting who can reconfigure networking. === App Layer Usage -For dynamic nested resources, use `LookupPath` to traverse the hierarchy: +For dynamic resources, use `LookupPath` to look up the resource: [source,rust] ---- use nexus_db_queries::db::lookup::LookupPath; -// Simple nested lookup +// Top-level resource +let (.., authz_ip_pool) = LookupPath::new(&opctx, &datastore) + .ip_pool_id(pool_id) + .fetch() + .await?; + +opctx.authorize(authz::Action::Read, &authz_ip_pool).await?; + +// Nested resource let (.., authz_disk) = LookupPath::new(&opctx, &datastore) .disk_id(disk_id) .fetch() @@ -417,7 +390,7 @@ let (.., authz_disk) = LookupPath::new(&opctx, &datastore) opctx.authorize(authz::Action::Read, &authz_disk).await?; -// Multi-level nested lookup +// Multi-level nested resource let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) .project_id(project_id) .vpc_id(vpc_id) @@ -628,9 +601,10 @@ This verifies all authz types are registered and tests permissions exhaustively. To add authz for a new resource: -1. Identify which category your resource falls into +1. Identify which category your resource falls into (static or dynamic) 2. Follow the example for that category -3. Register with Oso in `oso_generic.rs` -4. Add to policy tests in `policy_test/resources.rs` -5. Integrate into request flow: HTTP → LookupPath → authorize → datastore -6. Test with `cargo nextest run -p nexus-db-queries test_policy` +3. For dynamic resources, choose the appropriate `polar_snippet` based on where the resource lives +4. Register with Oso in `oso_generic.rs` +5. Add to policy tests in `policy_test/resources.rs` +6. Integrate into request flow: HTTP → LookupPath → authorize → datastore +7. Test with `cargo nextest run -p nexus-db-queries test_policy` From 26560e22c47c7311ade49827a460ca7f6b25721f Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 18:51:53 -0800 Subject: [PATCH 05/12] claude: add examples --- docs/adding-authz.adoc | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc index 5a2cf5840c7..b774f4a4999 100644 --- a/docs/adding-authz.adoc +++ b/docs/adding-authz.adoc @@ -26,7 +26,11 @@ This separation keeps policy decisions in the Polar file where they belong, maki == Static Resources -Singleton resources representing system-wide concepts or synthetic collections. These are almost always synthetic resources that don't correspond to database entities. Examples: `Fleet`, `Inventory`, `DnsConfig`, `SiloCertificateList`, `VpcList`. +Singleton resources representing system-wide concepts or synthetic collections. These are almost always synthetic resources that don't correspond to database entities. + +**Static top-level examples:** `Fleet`, `Inventory`, `DnsConfig`, `BlueprintConfig`, `QuiesceState`, `AuditLog`, `IpPoolList`, `DeviceAuthRequestList` + +**Static child examples:** `SiloCertificateList`, `SiloUserList`, `SiloGroupList`, `SiloIdentityProviderList`, `VpcList` === Example: Inventory (static top-level resource) @@ -241,10 +245,18 @@ opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; == Dynamic Resources -Resources with multiple instances that correspond to database entities. This is the most common case. Examples: `Silo`, `IpPool`, `Disk`, `VpcSubnet`. +Resources with multiple instances that correspond to database entities. This is the most common case. All dynamic resources use the `authz_resource!` macro. The key difference is which `polar_snippet` you choose based on where the resource lives in the hierarchy. +**Examples by polar_snippet:** + +* `FleetChild`: `IpPool`, `Rack`, `Sled`, `Blueprint`, `AddressLot`, `SwitchPort`, `Service` +* `InProjectLimited`: `Disk`, `Instance`, `Snapshot`, `ProjectImage`, `FloatingIp`, `AffinityGroup` +* `InProjectFull`: `Vpc`, `VpcSubnet`, `VpcRouter`, `RouterRoute`, `InternetGateway`, `ExternalSubnet` +* `InSilo`: `SiloImage`, `Image` +* `Custom`: `Silo`, `Project`, `SshKey`, `Certificate`, `SiloUser`, `IdentityProvider`, `MulticastGroup` + === Example: IpPool (fleet-level resource) **In nexus/auth/src/authz/api_resources.rs:** From 3b3d00804868e5bbdf77f55f9e71567c6e301414 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 18:52:10 -0800 Subject: [PATCH 06/12] dap edits, part 1 --- docs/adding-authz.adoc | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc index b774f4a4999..5d2e684a877 100644 --- a/docs/adding-authz.adoc +++ b/docs/adding-authz.adoc @@ -4,26 +4,45 @@ == Overview -This document explains how to add authorization (authz) support for new resources in Omicron. Authorization is based on role-based access control (RBAC) using the Oso policy engine. +This document explains how to add authorization (authz) support for new resources in Nexus. Authorization is based on role-based access control (RBAC) using the Oso policy engine. + +== Principles Before implementing authz for a new resource, read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. -Resources fall into three categories, each with a different implementation pattern. This document provides complete examples for each category that you can adapt for your resource. +The most important thing to know is that every authz check we have today boils down to asking: -== Principles +* is this **actor** (a silo user or internal system user) +* allowed to perform this **action** +* on this **resource** -=== Authz Checks Should Be Close to the Action +It usually looks like this: -Perform authorization checks as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the operation they protect. For example, if you have a datastore function that modifies a resource, that function should accept the authz type and perform the authorization check, rather than expecting the caller to have done it. +```rust +opctx.authorize(authz::Action::Modify, &authz::INVENTORY) +``` + +Here: + +- `opctx` is ubiquitous in Nexus. Constructing it requires authenticating the user and it describes the actor for this check. +- `authz::Action` is an enum with just a handful of standard actions. +- `authz::Inventory` is the resource. It's an **authz object**. === Avoid Baking Policy Into Authz Checks -This is important and non-obvious. It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet." Instead, prefer creating a synthetic resource whose *policy* (in the Polar file) reflects that equivalence, while the authz check itself is straightforward: "I'm modifying the inventory, so I check authorization on the inventory resource." +This is important and non-obvious. It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet." Instead, prefer creating a synthetic resource whose *policy* (in the Polar file) reflects that equivalence, while the authz check itself is almost a literal translation of the action you're taking: "I'm modifying the inventory, so I check if the user can perform the `modify` action on the `inventory` resource." For example, rather than checking Fleet authorization when modifying inventory, we define an `Inventory` resource whose Polar policy says that Fleet admins have modify permissions on Inventory. The code just checks `authorize(Action::Modify, &INVENTORY)` without knowing anything about the Fleet. This separation keeps policy decisions in the Polar file where they belong, making the system easier to understand and modify. +=== Authz Checks Should Be Close to the Action + +Perform authorization checks as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the operation they protect. For example, if you have a datastore function that modifies a resource, that function should accept the authz type and perform the authorization check, rather than expecting the caller to have done it. + + +Resources fall into three categories, each with a different implementation pattern. This document provides complete examples for each category that you can adapt for your resource. + == Static Resources Singleton resources representing system-wide concepts or synthetic collections. These are almost always synthetic resources that don't correspond to database entities. From e02c6d83077e79ef194c7f6ef500f7516ed6b9d0 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 19:51:53 -0800 Subject: [PATCH 07/12] dap: edits --- docs/adding-authz.adoc | 417 ++++++++++++++++++++++------------------- 1 file changed, 223 insertions(+), 194 deletions(-) diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc index 5d2e684a877..edff75c0286 100644 --- a/docs/adding-authz.adoc +++ b/docs/adding-authz.adoc @@ -6,8 +6,6 @@ This document explains how to add authorization (authz) support for new resources in Nexus. Authorization is based on role-based access control (RBAC) using the Oso policy engine. -== Principles - Before implementing authz for a new resource, read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. The most important thing to know is that every authz check we have today boils down to asking: @@ -24,26 +22,219 @@ opctx.authorize(authz::Action::Modify, &authz::INVENTORY) Here: -- `opctx` is ubiquitous in Nexus. Constructing it requires authenticating the user and it describes the actor for this check. +- `opctx` identifies the actor. This object is ubiquitous in Nexus and constructing it requires authenticating the user. - `authz::Action` is an enum with just a handful of standard actions. -- `authz::Inventory` is the resource. It's an **authz object**. +- `authz::Inventory` is the resource. It's an **authz type** (or **authz object**). + +`opctx.authorize()` checks the system **policy**, which is the set of rules that produces a boolean answer to the question "can this actor perform this action on this resource?". Our policy is defined in a language called Polar in a file called `omicron.polar`. + +Altogether, the authz subsystem comprises: + +* `omicron.polar`, the policy rules that define who can do what +* the authz types (Rust types that correspond with resources -- these also correspond directly with types in the Polar file) +* the authz checks scattered throughout the code base, all in terms of the authz types +* the implementation of `authorize()` (you can ignore this for the purposes of this document): +** storage and queries for accessing role assignments in the database +** evaluation of Polar policy + +=== Authz types, synthetic resources + +Authz types are Rust types that represent **resources** on which we do authz checks. They're exported from the `authz` module. As local variables, we usually prefix them with `authz`, as in `authz_instance` to distinguish it from other representations (like `db_instance` for the database record for an instance). + +Many authz types correspond directly with API resources and database types that you already know: `authz::Project`, `authz::Instance`, etc. + +Others are called **synthetic resources**: these are Rust types that we've defined to represent something we want to do authz checks on, but that don't correspond to anything in the database. For example, `authz::FLEET` represents "the whole control plane".footnote:["Fleet" here is probably something of a misnomer.] `authz::SiloCertificateList(SiloUuid)` represents "the list of TLS certificates for a _given_ Silo. + +Synthetic resources help us **separate policy from implementation**. Suppose we're adding authz to the API endpoint that lists the items in an IP pool. What should the authz check be? By defining a synthetic resource, the answer is simple: it's just: + +```rust +opctx.authorize(authz::Action::ListChildren, &authz::IP_POOL_LIST).await; +``` + +This is almost a literal translation of what we're doing: we're (listing the children) of the (IP pool list). This is by design. The question of _who is allowed to list the children of the IP pool list_ is a question of policy. That doesn't belong in the code doing the authz checks. + +It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet. I'll just use `authz::FLEET` ". That's encoding the policy directly in the implementation. Instead, prefer creating a synthetic resource whose policy (in the Polar file) reflects that it's equivalent to `Fleet`, while the authz check itself remains almost a literal translation of the action you're taking. For example, rather than checking Fleet authorization when modifying inventory, we define an `Inventory` resource whose Polar policy says that Fleet admins have modify permissions on Inventory. The code just checks `authorize(Action::Modify, &INVENTORY)` without knowing anything about the Fleet. + +This can sound like make-work, but this principle (separating policy from implementation) is very powerful: + +* Writing, modifying, and reviewing implementation code is easy because all the authz checks just check exactly the thing you're doing. You don't have to reason about the authz policy while looking at the implementation. +* Writing, modifying, and reviewing the policy is also easier without having to consider the implementation. +* We're able to write xref:../nexus/db-queries/src/policy_test/mod.rs[totally comprehensive tests] on the policy without having to exercise every single code path in Nexus. +* From the xref:../nexus/db-queries/tests/output/authz-roles.out[output of that test], readers (including non-engineers) are able to answer policy questions (like "what actions can a silo collaborator perform on a project in their silo") without having to look at any code. +* It allows us to change the policy and potentially even the entire user model and `authz.authorize()` implementation without changing any of the authz checks in the system. + +== Adding new authz resources + +Although there's some boilerplate in adding new resources, it should be pretty easy. authz resources generally fall into one of a few categories, each with a slightly different implementation pattern: + +* <<_dynamic_resources>> generally correspond with API resources and database objects. This includes `Silo`, `Project`, etc. +* <<_static_resources>> are generally synthetic resources that represent either some broad concept (like `Inventory`, which is "all the information we have about the system's current hardware and software configuration") or a made-up collection (like `SiloCertificateList`, which is "the list of TLS certificates in a particular Silo"). They can either be at the top level (like `Inventory`) or nested under some dynamic resource (the way `SiloCertificateList` is nested under a _specific_ Silo). + +NOTE: There's another explanation for why we have synthetic resources: it allows us to have a uniform set of just a handful of actions: `read`, `modify`, etc. The alternative would be to define a lot of different and heterogenous actions. For example, instead of having `SiloCertificateList` and `SiloUserList` on which you can `list_children`, we'd have just `Silo` on which you could `list_certificates` and `list_users`, etc. For various reasons it's been very helpful to have a small, uniform set of actions. + +=== Adding New Dynamic Resources + +These are resources with multiple instances that correspond to database entities. This is the most common case. We'll look at how the `IpPool` resource is defined as a template. + +==== Example: IpPool (fleet-level resource) + +In `nexus/auth/src/authz/api_resources.rs`, we define the authz type with: + +[source,rust] +---- +authz_resource! { + name = "IpPool", + parent = "Fleet", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = FleetChild, +} +---- + +In `nexus/auth/src/authz/oso_generic.rs`, the authz type is added to the `generated_inits` array in `make_omicron_oso()`: + +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + IpPool::init(), + // ... more resources ... +]; + +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} +---- + +In `nexus/db-queries/src/policy_test/resources.rs`, the test defines instances of the authz type in order to test what permissions different roles have on it. + +==== Example: Disk (project-level resource) + +**In nexus/auth/src/authz/api_resources.rs:** + +[source,rust] +---- +authz_resource! { + name = "Disk", + parent = "Project", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectLimited, +} +---- + +**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: + +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + Disk::init(), + // ... more resources ... +]; + +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} +---- + +**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper: + +[source,rust] +---- +async fn make_project(/* ... */) { + let project = authz::Project::new(/* ... */); + // ... + builder.new_resource(authz::Disk::new( + project.clone(), + Uuid::new_v4(), + LookupType::ByName(format!("{}-disk1", project_name)), + )); +} +---- + +==== Example: VpcSubnet (project networking resource, nested under Vpc) + +**In nexus/auth/src/authz/api_resources.rs:** + +[source,rust] +---- +authz_resource! { + name = "VpcSubnet", + parent = "Vpc", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectFull, +} +---- + +**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: + +[source,rust] +---- +let generated_inits = [ + // ... existing resources ... + VpcSubnet::init(), + // ... more resources ... +]; + +for init in generated_inits { + oso_builder = oso_builder.register_class_with_snippet(init)?; +} +---- + +**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper. + +==== Choosing the Right Polar Snippet + +The `polar_snippet` parameter controls what Polar policy is generated: -=== Avoid Baking Policy Into Authz Checks +* `FleetChild`: For fleet-level resources. Grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. Examples: `IpPool`, `Rack`, `Sled`, `Blueprint`, `AddressLot`, `SwitchPort`, `Service`. +* `InSilo`: For Silo-scoped resources. Grants permissions based on Silo roles. Examples: `SiloImage`, `Image`. +* `InProjectLimited`: For Project compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` can create and modify these. Examples: `Disk`, `Instance`, `Snapshot`, `ProjectImage`, `FloatingIp`, `AffinityGroup`. +* `InProjectFull`: For Project networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role. Examples: `Vpc`, `VpcSubnet`, `VpcRouter`, `RouterRoute`, `InternetGateway`, `ExternalSubnet`. +* `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. Examples: `Silo`, `Project`, `SshKey`, `Certificate`, `SiloUser`, `IdentityProvider`, `MulticastGroup` -This is important and non-obvious. It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet." Instead, prefer creating a synthetic resource whose *policy* (in the Polar file) reflects that equivalence, while the authz check itself is almost a literal translation of the action you're taking: "I'm modifying the inventory, so I check if the user can perform the `modify` action on the `inventory` resource." +The distinction between `InProjectLimited` and `InProjectFull` allows organizations to give users access to compute resources while restricting who can reconfigure networking. -For example, rather than checking Fleet authorization when modifying inventory, we define an `Inventory` resource whose Polar policy says that Fleet admins have modify permissions on Inventory. The code just checks `authorize(Action::Modify, &INVENTORY)` without knowing anything about the Fleet. +==== App Layer Usage -This separation keeps policy decisions in the Polar file where they belong, making the system easier to understand and modify. +For dynamic resources, use `LookupPath` to look up the resource: + +[source,rust] +---- +use nexus_db_queries::db::lookup::LookupPath; -=== Authz Checks Should Be Close to the Action +// Top-level resource +let (.., authz_ip_pool) = LookupPath::new(&opctx, &datastore) + .ip_pool_id(pool_id) + .fetch() + .await?; + +opctx.authorize(authz::Action::Read, &authz_ip_pool).await?; + +// Nested resource +let (.., authz_disk) = LookupPath::new(&opctx, &datastore) + .disk_id(disk_id) + .fetch() + .await?; + +opctx.authorize(authz::Action::Read, &authz_disk).await?; + +// Multi-level nested resource +let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) + .project_id(project_id) + .vpc_id(vpc_id) + .vpc_subnet_id(subnet_id) + .fetch() + .await?; -Perform authorization checks as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the operation they protect. For example, if you have a datastore function that modifies a resource, that function should accept the authz type and perform the authorization check, rather than expecting the caller to have done it. +opctx.authorize(authz::Action::Modify, &authz_subnet).await?; +---- -Resources fall into three categories, each with a different implementation pattern. This document provides complete examples for each category that you can adapt for your resource. -== Static Resources +=== Static Resources Singleton resources representing system-wide concepts or synthetic collections. These are almost always synthetic resources that don't correspond to database entities. @@ -51,7 +242,7 @@ Singleton resources representing system-wide concepts or synthetic collections. **Static child examples:** `SiloCertificateList`, `SiloUserList`, `SiloGroupList`, `SiloIdentityProviderList`, `VpcList` -=== Example: Inventory (static top-level resource) +==== Example: Inventory (static top-level resource) **In nexus/auth/src/authz/api_resources.rs:** @@ -135,7 +326,7 @@ let classes = [ builder.new_resource(authz::INVENTORY); ---- -=== Example: SiloCertificateList (static child resource) +==== Example: SiloCertificateList (static child resource) **In nexus/auth/src/authz/api_resources.rs:** @@ -235,7 +426,7 @@ async fn make_silo(/* ... */) { } ---- -=== App Layer Usage +==== App Layer Usage For static top-level resources, use the global constant directly: @@ -262,181 +453,11 @@ let authz_cert_list = authz::SiloCertificateList::new(authz_silo); opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; ---- -== Dynamic Resources - -Resources with multiple instances that correspond to database entities. This is the most common case. - -All dynamic resources use the `authz_resource!` macro. The key difference is which `polar_snippet` you choose based on where the resource lives in the hierarchy. - -**Examples by polar_snippet:** - -* `FleetChild`: `IpPool`, `Rack`, `Sled`, `Blueprint`, `AddressLot`, `SwitchPort`, `Service` -* `InProjectLimited`: `Disk`, `Instance`, `Snapshot`, `ProjectImage`, `FloatingIp`, `AffinityGroup` -* `InProjectFull`: `Vpc`, `VpcSubnet`, `VpcRouter`, `RouterRoute`, `InternetGateway`, `ExternalSubnet` -* `InSilo`: `SiloImage`, `Image` -* `Custom`: `Silo`, `Project`, `SshKey`, `Certificate`, `SiloUser`, `IdentityProvider`, `MulticastGroup` - -=== Example: IpPool (fleet-level resource) - -**In nexus/auth/src/authz/api_resources.rs:** - -[source,rust] ----- -authz_resource! { - name = "IpPool", - parent = "Fleet", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = FleetChild, -} ----- - -**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array in `make_omicron_oso()`: - -[source,rust] ----- -let generated_inits = [ - // ... existing resources ... - IpPool::init(), - // ... more resources ... -]; - -for init in generated_inits { - oso_builder = oso_builder.register_class_with_snippet(init)?; -} ----- - -**In nexus/db-queries/src/policy_test/resources.rs,** add test instances. - -=== Example: Disk (project compute resource) - -**In nexus/auth/src/authz/api_resources.rs:** - -[source,rust] ----- -authz_resource! { - name = "Disk", - parent = "Project", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = InProjectLimited, -} ----- - -**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: - -[source,rust] ----- -let generated_inits = [ - // ... existing resources ... - Disk::init(), - // ... more resources ... -]; - -for init in generated_inits { - oso_builder = oso_builder.register_class_with_snippet(init)?; -} ----- - -**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper: - -[source,rust] ----- -async fn make_project(/* ... */) { - let project = authz::Project::new(/* ... */); - // ... - builder.new_resource(authz::Disk::new( - project.clone(), - Uuid::new_v4(), - LookupType::ByName(format!("{}-disk1", project_name)), - )); -} ----- - -=== Example: VpcSubnet (project networking resource, nested under Vpc) - -**In nexus/auth/src/authz/api_resources.rs:** - -[source,rust] ----- -authz_resource! { - name = "VpcSubnet", - parent = "Vpc", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = InProjectFull, -} ----- - -**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: - -[source,rust] ----- -let generated_inits = [ - // ... existing resources ... - VpcSubnet::init(), - // ... more resources ... -]; - -for init in generated_inits { - oso_builder = oso_builder.register_class_with_snippet(init)?; -} ----- - -**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper. - -=== Choosing the Right Polar Snippet - -The `polar_snippet` parameter controls what Polar policy is generated: - -* `FleetChild`: For fleet-level resources. Grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. -* `InSilo`: For Silo-scoped resources. Grants permissions based on Silo roles. -* `InProjectLimited`: For Project compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` can create and modify these. -* `InProjectFull`: For Project networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role. -* `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. See `Silo`, `Project`, `SshKey`, or `Certificate` for examples. - -The distinction between `InProjectLimited` and `InProjectFull` allows organizations to give users access to compute resources while restricting who can reconfigure networking. - -=== App Layer Usage - -For dynamic resources, use `LookupPath` to look up the resource: - -[source,rust] ----- -use nexus_db_queries::db::lookup::LookupPath; - -// Top-level resource -let (.., authz_ip_pool) = LookupPath::new(&opctx, &datastore) - .ip_pool_id(pool_id) - .fetch() - .await?; +== Supporting roles on resources -opctx.authorize(authz::Action::Read, &authz_ip_pool).await?; +**Roles** are constructs within our policy. We say that Fleet, Silo, and Project each have a handful of roles (like `viewer`, `collaborator`, and `admin`). Permissions in our system flow from those roles. Although the authz system supports dozens of different resources, authz checks ultimately boil down to checking whether a user has one of a few roles on these three resources. -// Nested resource -let (.., authz_disk) = LookupPath::new(&opctx, &datastore) - .disk_id(disk_id) - .fetch() - .await?; - -opctx.authorize(authz::Action::Read, &authz_disk).await?; - -// Multi-level nested resource -let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) - .project_id(project_id) - .vpc_id(vpc_id) - .vpc_subnet_id(subnet_id) - .fetch() - .await?; - -opctx.authorize(authz::Action::Modify, &authz_subnet).await?; ----- - -== Supporting Role Assignments - -Most resources do not support role assignments directly. Roles are typically assigned only to high-level resources like Fleet, Silo, and Project. If your resource needs to support role assignments: - -=== Steps +Most of the time when you're adding a new resource, you'll define the policy for that resource in terms of these existing roles and you can ignore this whole section. **1. Define the roles enum** in `nexus/types/src/external_api/shared.rs`: @@ -493,11 +514,11 @@ impl ApiResourceWithRolesType for YourResource { **5. Define custom Polar policy** in `omicron.polar` that defines the roles and their permissions. See `Project` or `Silo` for examples. -== HTTP and Datastore Integration +== Authz at the HTTP, App, and Datastore Layers === HTTP Layer -Accept raw identifiers (UUID or name) from the user: +This layer generally accepts raw identifiers (UUID or name) from the user: [source,rust] ---- @@ -513,9 +534,15 @@ async fn disk_view( } ---- -=== Performing Authorization +=== App Layer -Once you have the authz type (from LookupPath or a global constant), check authorization before proceeding: +The details of authz at the app layer depend on the kind of resource. See the section above. + +In the end, though, you'll wind up having resolved the user-provided identifier to an `authz` object that can be used for doing authz checks. + +=== Performing Authorization Checks + +Once you have the authz type (from LookupPath, a global constant, or a hand-constructed object based on one of those), you can check authorization using something like: [source,rust] ---- @@ -523,11 +550,13 @@ opctx.authorize(authz::Action::Read, &authz_disk).await?; let disk = datastore.disk_fetch(&opctx, &authz_disk).await?; ---- -Common actions: `Read`, `Modify`, `Delete`, `ListChildren`, `CreateChild`. +Available actions include: `Read`, `Modify`, `Delete`, `ListChildren`, `CreateChild`. + +Generally, authz checks should be performed as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the action they protect (leaving some code paths unchecked). For example, if you have a datastore function that modifies a resource, that function should do the authz check. More in the next section. === Datastore Layer -Datastore functions accept authz types, not raw UUIDs. This ensures the resource has been looked up and basic authz checks have been done: +Datastore functions accept authz types, not raw UUIDs. This provides the context needed to perform authz checks. [source,rust] ---- From 6a3454860887e38fca413f56a9bf776cc3986691 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 19:55:08 -0800 Subject: [PATCH 08/12] claude: edits --- docs/adding-authz.adoc | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/adding-authz.adoc b/docs/adding-authz.adoc index edff75c0286..629da3414b4 100644 --- a/docs/adding-authz.adoc +++ b/docs/adding-authz.adoc @@ -110,7 +110,7 @@ In `nexus/db-queries/src/policy_test/resources.rs`, the test defines instances o ==== Example: Disk (project-level resource) -**In nexus/auth/src/authz/api_resources.rs:** +In `nexus/auth/src/authz/api_resources.rs`, the authz type is defined with: [source,rust] ---- @@ -123,7 +123,7 @@ authz_resource! { } ---- -**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: +In `nexus/auth/src/authz/oso_generic.rs`, the authz type is added to the `generated_inits` array: [source,rust] ---- @@ -138,7 +138,7 @@ for init in generated_inits { } ---- -**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper: +In `nexus/db-queries/src/policy_test/resources.rs`, test instances are created in the parent's helper: [source,rust] ---- @@ -155,7 +155,7 @@ async fn make_project(/* ... */) { ==== Example: VpcSubnet (project networking resource, nested under Vpc) -**In nexus/auth/src/authz/api_resources.rs:** +In `nexus/auth/src/authz/api_resources.rs`, the authz type is defined with: [source,rust] ---- @@ -168,7 +168,7 @@ authz_resource! { } ---- -**In nexus/auth/src/authz/oso_generic.rs,** add to the `generated_inits` array: +In `nexus/auth/src/authz/oso_generic.rs`, the authz type is added to the `generated_inits` array: [source,rust] ---- @@ -183,7 +183,7 @@ for init in generated_inits { } ---- -**In nexus/db-queries/src/policy_test/resources.rs,** add instances in the parent's helper. +In `nexus/db-queries/src/policy_test/resources.rs`, test instances are created in the parent's helper. ==== Choosing the Right Polar Snippet @@ -244,7 +244,7 @@ Singleton resources representing system-wide concepts or synthetic collections. ==== Example: Inventory (static top-level resource) -**In nexus/auth/src/authz/api_resources.rs:** +In `nexus/auth/src/authz/api_resources.rs`, the type is defined with: [source,rust] ---- @@ -294,7 +294,7 @@ impl AuthorizedResource for Inventory { } ---- -**In nexus/auth/src/authz/omicron.polar:** +In `nexus/auth/src/authz/omicron.polar`, the Polar policy is defined: [source,polar] ---- @@ -383,7 +383,7 @@ impl AuthorizedResource for SiloCertificateList { } ---- -**In nexus/auth/src/authz/omicron.polar:** +In `nexus/auth/src/authz/omicron.polar`, the Polar policy is defined: [source,polar] ---- From f58327e67211c7101d65a3f54f6f5505fa3581ea Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Thu, 29 Jan 2026 20:28:48 -0800 Subject: [PATCH 09/12] dap: edit and rename --- ...adding-authz.adoc => authz-dev-guide.adoc} | 370 +++++++++--------- 1 file changed, 180 insertions(+), 190 deletions(-) rename docs/{adding-authz.adoc => authz-dev-guide.adoc} (79%) diff --git a/docs/adding-authz.adoc b/docs/authz-dev-guide.adoc similarity index 79% rename from docs/adding-authz.adoc rename to docs/authz-dev-guide.adoc index 629da3414b4..f86cbdbcd88 100644 --- a/docs/adding-authz.adoc +++ b/docs/authz-dev-guide.adoc @@ -1,4 +1,4 @@ -= Adding Authorization for Resources += Authz Developer Guide :toc: left :toclevels: 3 @@ -53,6 +53,8 @@ opctx.authorize(authz::Action::ListChildren, &authz::IP_POOL_LIST).await; This is almost a literal translation of what we're doing: we're (listing the children) of the (IP pool list). This is by design. The question of _who is allowed to list the children of the IP pool list_ is a question of policy. That doesn't belong in the code doing the authz checks. +NOTE: There's another explanation for why we have synthetic resources: it allows us to have a uniform set of just a handful of actions: `read`, `modify`, etc. The alternative would be to define a lot of different and heterogenous actions. For example, instead of having `SiloCertificateList` and `SiloUserList` on which you can `list_children`, we'd have just `Silo` on which you could `list_certificates` and `list_users`, etc. For various reasons it's been very helpful to have a small, uniform set of actions. + It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet. I'll just use `authz::FLEET` ". That's encoding the policy directly in the implementation. Instead, prefer creating a synthetic resource whose policy (in the Polar file) reflects that it's equivalent to `Fleet`, while the authz check itself remains almost a literal translation of the action you're taking. For example, rather than checking Fleet authorization when modifying inventory, we define an `Inventory` resource whose Polar policy says that Fleet admins have modify permissions on Inventory. The code just checks `authorize(Action::Modify, &INVENTORY)` without knowing anything about the Fleet. This can sound like make-work, but this principle (separating policy from implementation) is very powerful: @@ -63,18 +65,106 @@ This can sound like make-work, but this principle (separating policy from implem * From the xref:../nexus/db-queries/tests/output/authz-roles.out[output of that test], readers (including non-engineers) are able to answer policy questions (like "what actions can a silo collaborator perform on a project in their silo") without having to look at any code. * It allows us to change the policy and potentially even the entire user model and `authz.authorize()` implementation without changing any of the authz checks in the system. -== Adding new authz resources +=== Authz at the HTTP, App, and Datastore Layers -Although there's some boilerplate in adding new resources, it should be pretty easy. authz resources generally fall into one of a few categories, each with a slightly different implementation pattern: +==== HTTP Layer -* <<_dynamic_resources>> generally correspond with API resources and database objects. This includes `Silo`, `Project`, etc. -* <<_static_resources>> are generally synthetic resources that represent either some broad concept (like `Inventory`, which is "all the information we have about the system's current hardware and software configuration") or a made-up collection (like `SiloCertificateList`, which is "the list of TLS certificates in a particular Silo"). They can either be at the top level (like `Inventory`) or nested under some dynamic resource (the way `SiloCertificateList` is nested under a _specific_ Silo). +This layer generally accepts raw identifiers (UUID or name) from the user: -NOTE: There's another explanation for why we have synthetic resources: it allows us to have a uniform set of just a handful of actions: `read`, `modify`, etc. The alternative would be to define a lot of different and heterogenous actions. For example, instead of having `SiloCertificateList` and `SiloUserList` on which you can `list_children`, we'd have just `Silo` on which you could `list_certificates` and `list_users`, etc. For various reasons it's been very helpful to have a small, uniform set of actions. +[source,rust] +---- +#[endpoint { + method = GET, + path = "/v1/disks/{disk}", +}] +async fn disk_view( + rqctx: RequestContext>, + path_params: Path, +) -> Result, HttpError> { + // ... +} +---- + +==== App Layer + +The details of authz at the app layer depend on the kind of resource. See the section above. + +In the end, though, you'll wind up having resolved the user-provided identifier to an `authz` object that can be used for doing authz checks. + +==== Performing Authorization Checks + +Once you have the authz type (from LookupPath, a global constant, or a hand-constructed object based on one of those), you can check authorization using something like: + +[source,rust] +---- +opctx.authorize(authz::Action::Read, &authz_disk).await?; +let disk = datastore.disk_fetch(&opctx, &authz_disk).await?; +---- + +Available actions include: `Read`, `Modify`, `Delete`, `ListChildren`, `CreateChild`. + +Generally, authz checks should be performed as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the action they protect (leaving some code paths unchecked). For example, if you have a datastore function that modifies a resource, that function should do the authz check. More in the next section. + +==== Datastore Layer + +Datastore functions accept authz types, not raw UUIDs. This provides the context needed to perform authz checks. + +[source,rust] +---- +impl DataStore { + pub async fn disk_fetch( + &self, + opctx: &OpContext, + authz_disk: &authz::Disk, + ) -> Result { + let disk_id = authz_disk.id(); + // ... query database ... + } + + pub async fn disk_create( + &self, + opctx: &OpContext, + authz_project: &authz::Project, // Parent resource + disk: db::model::Disk, + ) -> Result { + opctx.authorize(authz::Action::CreateChild, authz_project).await?; + // ... insert into database ... + } +} +---- + +==== Key Files + +* `nexus/auth/src/authz/mod.rs` - Overview of authz subsystem +* `nexus/auth/src/authz/api_resources.rs` - Authz type definitions +* `nexus/auth/src/authz/omicron.polar` - Polar policy rules +* `nexus/auth/src/authz/oso_generic.rs` - Oso initialization +* `nexus/authz-macros/src/lib.rs` - `authz_resource!` macro +* `nexus/db-queries/src/policy_test/resources.rs` - Policy test setup +* `nexus/db-queries/src/db/lookup.rs` - LookupPath implementation +* `nexus/types/src/external_api/shared.rs` - Role enum definitions + +== Adding New Authz Resources + +Authz resources generally fall into one of a few categories, each with a slightly different implementation pattern: + +* <<_adding_new_dynamic_resources,Dynamic, database-based resources>> correspond with API resources and database objects. This includes `Silo`, `Project`, etc. +* <<_adding_new_synthetic_resources,Synthetic resources>> represent either some broad concept (like `Inventory`, which is "all the information we have about the system's current hardware and software configuration") or a made-up collection (like `SiloCertificateList`, which is "the list of TLS certificates in a particular Silo"). They can either be at the top level (like `Inventory`) or nested under some dynamic resource (the way `SiloCertificateList` is nested under a _specific_ Silo). + +After making any of the changes below, run the policy test: + +[source,shell] +---- +cargo nextest run -p nexus-db-queries -- policy_test +---- + +This verifies all authz types are registered and tests permissions exhaustively. + +You'll also want to run integration tests for Nexus, including those for your endpoints as well as the "unauthorized" and "unauthorized_coverage" tests. === Adding New Dynamic Resources -These are resources with multiple instances that correspond to database entities. This is the most common case. We'll look at how the `IpPool` resource is defined as a template. +These are resources with multiple instances that correspond to database entities. This is the most common case. We'll look at how several example resources are defined. You can generally start by copying one that looks similar to what you're trying to do. ==== Example: IpPool (fleet-level resource) @@ -187,13 +277,13 @@ In `nexus/db-queries/src/policy_test/resources.rs`, test instances are created i ==== Choosing the Right Polar Snippet -The `polar_snippet` parameter controls what Polar policy is generated: +The `polar_snippet` parameter controls what Polar policy is generated. These are shorthands for common policies so that you don't have to write them by hand: * `FleetChild`: For fleet-level resources. Grants `read` and `list_children` to `fleet.viewer`, and `modify` and `create_child` to `fleet.admin`. Examples: `IpPool`, `Rack`, `Sled`, `Blueprint`, `AddressLot`, `SwitchPort`, `Service`. * `InSilo`: For Silo-scoped resources. Grants permissions based on Silo roles. Examples: `SiloImage`, `Image`. * `InProjectLimited`: For Project compute resources (instances, disks, snapshots, images, floating IPs). Users with `limited-collaborator` can create and modify these. Examples: `Disk`, `Instance`, `Snapshot`, `ProjectImage`, `FloatingIp`, `AffinityGroup`. * `InProjectFull`: For Project networking infrastructure (VPCs, subnets, routers, internet gateways). Requires full `collaborator` role. Examples: `Vpc`, `VpcSubnet`, `VpcRouter`, `RouterRoute`, `InternetGateway`, `ExternalSubnet`. -* `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. Examples: `Silo`, `Project`, `SshKey`, `Certificate`, `SiloUser`, `IdentityProvider`, `MulticastGroup` +* `Custom`: No generated policy. You write the entire Polar policy manually in `omicron.polar`. Examples: `Silo`, `Project`, `SshKey`, `Certificate`, `SiloUser`, `IdentityProvider`, `MulticastGroup`. The distinction between `InProjectLimited` and `InProjectFull` allows organizations to give users access to compute resources while restricting who can reconfigure networking. @@ -213,14 +303,6 @@ let (.., authz_ip_pool) = LookupPath::new(&opctx, &datastore) opctx.authorize(authz::Action::Read, &authz_ip_pool).await?; -// Nested resource -let (.., authz_disk) = LookupPath::new(&opctx, &datastore) - .disk_id(disk_id) - .fetch() - .await?; - -opctx.authorize(authz::Action::Read, &authz_disk).await?; - // Multi-level nested resource let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) .project_id(project_id) @@ -232,19 +314,70 @@ let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) opctx.authorize(authz::Action::Modify, &authz_subnet).await?; ---- +See the docs on `LookupPath` for more. +==== Primary Key Variants -=== Static Resources +===== Typed UUIDs (Recommended) -Singleton resources representing system-wide concepts or synthetic collections. These are almost always synthetic resources that don't correspond to database entities. +New code should use typed UUIDs for type safety: -**Static top-level examples:** `Fleet`, `Inventory`, `DnsConfig`, `BlueprintConfig`, `QuiesceState`, `AuditLog`, `IpPoolList`, `DeviceAuthRequestList` +[source,rust] +---- +authz_resource! { + name = "SiloUser", + parent = "Silo", + primary_key = { uuid_kind = SiloUserKind }, + roles_allowed = false, + polar_snippet = Custom, +} +---- + +===== Plain UUID + +For resources that haven't migrated to typed UUIDs yet: -**Static child examples:** `SiloCertificateList`, `SiloUserList`, `SiloGroupList`, `SiloIdentityProviderList`, `VpcList` +[source,rust] +---- +authz_resource! { + name = "Disk", + parent = "Project", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = InProjectLimited, +} +---- -==== Example: Inventory (static top-level resource) +===== Other Key Types -In `nexus/auth/src/authz/api_resources.rs`, the type is defined with: +For resources with non-UUID keys: + +[source,rust] +---- +authz_resource! { + name = "DeviceAuthRequest", + parent = "Fleet", + primary_key = String, // user_code + roles_allowed = false, + polar_snippet = FleetChild, +} +---- + +=== Adding New Synthetic Resources + +These are singleton resources representing system-wide concepts or synthetic collections. These don't correspond directly to anything stored in the database. + +**Top-level examples:** `Fleet`, `Inventory`, `DnsConfig`, `BlueprintConfig`, `QuiesceState`, `AuditLog`, `IpPoolList`, `DeviceAuthRequestList`. + +**Nested examples:** `SiloCertificateList`, `SiloUserList`, `SiloGroupList`, `SiloIdentityProviderList`, `VpcList`. + +Note that silo certificates _are_ stored in the database -- what we mean by `SiloCertificateList` being synthetic is that there's no database table or row for the _list_ of silo certificates. + +==== Example: Inventory (top-level synthetic resource) + +Synthetic resources are defined with a bunch of Rust boilerplate rather than the `authz_resource!` macro. Top-level ones also define a constant representing the singleton instance of this resource in the system. + +In `nexus/auth/src/authz/api_resources.rs`, the `Inventory` type and its singleton instance `INVENTORY` are defined with: [source,rust] ---- @@ -308,7 +441,7 @@ has_relation(fleet: Fleet, "parent_fleet", inventory: Inventory) if inventory.fleet = fleet; ---- -**In nexus/auth/src/authz/oso_generic.rs,** add to the `classes` array in `make_omicron_oso()`: +In `nexus/auth/src/authz/oso_generic.rs`, the authz type appears in the `classes` array in `make_omicron_oso()`: [source,rust] ---- @@ -319,16 +452,18 @@ let classes = [ ]; ---- -**In nexus/db-queries/src/policy_test/resources.rs,** add to `make_resources()`: +In `nexus/db-queries/src/policy_test/resources.rs`, the constant is used in `make_resources()`: [source,rust] ---- builder.new_resource(authz::INVENTORY); ---- -==== Example: SiloCertificateList (static child resource) +==== Example: SiloCertificateList (nested synthetic resource) + +As with top-level synthetic resources, these are defined with some boilerplate. These don't have global singletons though because they're always nested under some other dynamic type. The `add_attribute_getter` and `load_roles()` bits below will change slightly depending on the parent type. -**In nexus/auth/src/authz/api_resources.rs:** +In `nexus/auth/src/authz/api_resources.rs`: [source,rust] ---- @@ -404,7 +539,7 @@ has_relation(fleet: Fleet, "parent_fleet", collection: SiloCertificateList) if collection.silo.fleet = fleet; ---- -**In nexus/auth/src/authz/oso_generic.rs,** add to the `classes` array in `make_omicron_oso()`: +In `nexus/auth/src/authz/oso_generic.rs`, the authz type appears in the `classes` array in `make_omicron_oso()`: [source,rust] ---- @@ -415,7 +550,7 @@ let classes = [ ]; ---- -**In nexus/db-queries/src/policy_test/resources.rs,** instantiate in the parent's helper function: +In `nexus/db-queries/src/policy_test/resources.rs`, the authz type is used in the appropriate helper function: [source,rust] ---- @@ -428,7 +563,7 @@ async fn make_silo(/* ... */) { ==== App Layer Usage -For static top-level resources, use the global constant directly: +For synthetic top-level resources, use the global constant directly: [source,rust] ---- @@ -436,7 +571,7 @@ For static top-level resources, use the global constant directly: opctx.authorize(authz::Action::Modify, &authz::INVENTORY).await?; ---- -For static child resources, construct the collection by hand after looking up the parent: +For nested synthetic resources, construct the collection by hand after looking up the parent: [source,rust] ---- @@ -453,14 +588,16 @@ let authz_cert_list = authz::SiloCertificateList::new(authz_silo); opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; ---- -== Supporting roles on resources +== Supporting Roles On Resources **Roles** are constructs within our policy. We say that Fleet, Silo, and Project each have a handful of roles (like `viewer`, `collaborator`, and `admin`). Permissions in our system flow from those roles. Although the authz system supports dozens of different resources, authz checks ultimately boil down to checking whether a user has one of a few roles on these three resources. Most of the time when you're adding a new resource, you'll define the policy for that resource in terms of these existing roles and you can ignore this whole section. -**1. Define the roles enum** in `nexus/types/src/external_api/shared.rs`: +CAUTION: These instructions haven't really been tested since we've never added a _new_ resource with roles. This is more of a tour of the implementation of a role. +1. Define the roles enum in `nexus/types/src/external_api/shared.rs`: ++ [source,rust] ---- #[derive( @@ -488,10 +625,10 @@ impl DatabaseString for YourResourceRole { } ---- -**2. Add the SQL enum type** in `nexus/db-model/src/schema_versions.rs` (follow the pattern for existing role enums). - -**3. Set `roles_allowed = true`** in your `authz_resource!` invocation: +2. Add the SQL enum type in `nexus/db-model/src/schema_versions.rs` (follow the pattern for existing role enums). +3. Set `roles_allowed = true` in your `authz_resource!` invocation: ++ [source,rust] ---- authz_resource! { @@ -503,8 +640,8 @@ authz_resource! { } ---- -**4. Implement `ApiResourceWithRolesType`:** - +4. Implement `ApiResourceWithRolesType`: ++ [source,rust] ---- impl ApiResourceWithRolesType for YourResource { @@ -512,159 +649,12 @@ impl ApiResourceWithRolesType for YourResource { } ---- -**5. Define custom Polar policy** in `omicron.polar` that defines the roles and their permissions. See `Project` or `Silo` for examples. - -== Authz at the HTTP, App, and Datastore Layers - -=== HTTP Layer - -This layer generally accepts raw identifiers (UUID or name) from the user: +5. Define custom Polar policy in `omicron.polar` that defines the roles and their permissions. See `Project` or `Silo` for examples. -[source,rust] ----- -#[endpoint { - method = GET, - path = "/v1/disks/{disk}", -}] -async fn disk_view( - rqctx: RequestContext>, - path_params: Path, -) -> Result, HttpError> { - // ... -} ----- - -=== App Layer - -The details of authz at the app layer depend on the kind of resource. See the section above. - -In the end, though, you'll wind up having resolved the user-provided identifier to an `authz` object that can be used for doing authz checks. - -=== Performing Authorization Checks - -Once you have the authz type (from LookupPath, a global constant, or a hand-constructed object based on one of those), you can check authorization using something like: - -[source,rust] ----- -opctx.authorize(authz::Action::Read, &authz_disk).await?; -let disk = datastore.disk_fetch(&opctx, &authz_disk).await?; ----- - -Available actions include: `Read`, `Modify`, `Delete`, `ListChildren`, `CreateChild`. - -Generally, authz checks should be performed as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the action they protect (leaving some code paths unchecked). For example, if you have a datastore function that modifies a resource, that function should do the authz check. More in the next section. - -=== Datastore Layer - -Datastore functions accept authz types, not raw UUIDs. This provides the context needed to perform authz checks. - -[source,rust] ----- -impl DataStore { - pub async fn disk_fetch( - &self, - opctx: &OpContext, - authz_disk: &authz::Disk, - ) -> Result { - let disk_id = authz_disk.id(); - // ... query database ... - } - - pub async fn disk_create( - &self, - opctx: &OpContext, - authz_project: &authz::Project, // Parent resource - disk: db::model::Disk, - ) -> Result { - opctx.authorize(authz::Action::CreateChild, authz_project).await?; - // ... insert into database ... - } -} ----- - -== Primary Key Variants - -=== Typed UUIDs (Recommended) - -New code should use typed UUIDs for type safety: - -[source,rust] ----- -authz_resource! { - name = "SiloUser", - parent = "Silo", - primary_key = { uuid_kind = SiloUserKind }, - roles_allowed = false, - polar_snippet = Custom, -} ----- - -=== Plain UUID - -For resources that haven't migrated to typed UUIDs yet: - -[source,rust] ----- -authz_resource! { - name = "Disk", - parent = "Project", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = InProjectLimited, -} ----- - -=== Other Key Types - -For resources with non-UUID keys: - -[source,rust] ----- -authz_resource! { - name = "DeviceAuthRequest", - parent = "Fleet", - primary_key = String, // user_code - roles_allowed = false, - polar_snippet = FleetChild, -} ----- - -== Testing - -After implementing authz: - -**Run the policy test:** -[source,shell] ----- -cargo nextest run -p nexus-db-queries test_policy ----- - -This verifies all authz types are registered and tests permissions exhaustively. - -**Run integration tests** for your endpoints, verifying: -* Unauthorized users get 403/404 errors appropriately -* Authorized users can perform allowed operations -* Users can't exceed their permissions - -== Key Files - -* `nexus/auth/src/authz/mod.rs` - Overview of authz subsystem -* `nexus/auth/src/authz/api_resources.rs` - Authz type definitions -* `nexus/auth/src/authz/omicron.polar` - Polar policy rules -* `nexus/auth/src/authz/oso_generic.rs` - Oso initialization -* `nexus/authz-macros/src/lib.rs` - `authz_resource!` macro -* `nexus/db-queries/src/policy_test/resources.rs` - Policy test setup -* `nexus/db-queries/src/db/lookup.rs` - LookupPath implementation -* `nexus/types/src/external_api/shared.rs` - Role enum definitions +6. Define an API endpoint to read and update the policy on the resource. You can use the existing examples as templates. The bulk of the implementation is in common code that's generic over the resource type so you shouldn't need to do a lot of work here. -== Summary +== Defining New Roles -To add authz for a new resource: +See <<_supporting_roles_on_resources>>. To define a new role, you'll need to add it to the shared enum type and to the database enum type. You may also need a schema migration to add the new role name. -1. Identify which category your resource falls into (static or dynamic) -2. Follow the example for that category -3. For dynamic resources, choose the appropriate `polar_snippet` based on where the resource lives -4. Register with Oso in `oso_generic.rs` -5. Add to policy tests in `policy_test/resources.rs` -6. Integrate into request flow: HTTP → LookupPath → authorize → datastore -7. Test with `cargo nextest run -p nexus-db-queries test_policy` +The enum definition drives most of the rest of the system, including the set of roles that a user is allowed to set on an object. You don't need to change any of the endpoints that modify policy. From 10561fb291f9126a60b5ec2eff92261f8bc2b82e Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Fri, 30 Jan 2026 08:51:46 -0800 Subject: [PATCH 10/12] reframe role docs to be less imperative --- docs/authz-dev-guide.adoc | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/authz-dev-guide.adoc b/docs/authz-dev-guide.adoc index f86cbdbcd88..7d2f2f85232 100644 --- a/docs/authz-dev-guide.adoc +++ b/docs/authz-dev-guide.adoc @@ -594,10 +594,10 @@ opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; Most of the time when you're adding a new resource, you'll define the policy for that resource in terms of these existing roles and you can ignore this whole section. -CAUTION: These instructions haven't really been tested since we've never added a _new_ resource with roles. This is more of a tour of the implementation of a role. +Taking Project as an example, here's how roles are implemented: + +The roles enum is defined in `nexus/types/src/external_api/shared.rs`: -1. Define the roles enum in `nexus/types/src/external_api/shared.rs`: -+ [source,rust] ---- #[derive( @@ -614,47 +614,47 @@ CAUTION: These instructions haven't really been tested since we've never added a Serialize, )] #[serde(rename_all = "snake_case")] -pub enum YourResourceRole { +pub enum ProjectRole { Admin, Collaborator, Viewer, } -impl DatabaseString for YourResourceRole { - type SqlType = YourResourceRoleEnum; +impl DatabaseString for ProjectRole { + type SqlType = ProjectRoleEnum; } ---- -2. Add the SQL enum type in `nexus/db-model/src/schema_versions.rs` (follow the pattern for existing role enums). +There's a corresponding SQL enum type in `nexus/db-model/src/schema_versions.rs` that mirrors the Rust enum for database storage. + +The authz resource is configured with `roles_allowed = true`: -3. Set `roles_allowed = true` in your `authz_resource!` invocation: -+ [source,rust] ---- authz_resource! { - name = "YourResource", - parent = "Fleet", + name = "Project", + parent = "Silo", primary_key = Uuid, roles_allowed = true, - polar_snippet = Custom, // Usually need custom policy for resources with roles + polar_snippet = Custom, } ---- -4. Implement `ApiResourceWithRolesType`: -+ +Resources with roles typically use `Custom` polar snippets since they need custom policy definitions. + +The `ApiResourceWithRolesType` trait is implemented to link the resource to its roles enum: + [source,rust] ---- -impl ApiResourceWithRolesType for YourResource { - type AllowedRoles = YourResourceRole; +impl ApiResourceWithRolesType for Project { + type AllowedRoles = ProjectRole; } ---- -5. Define custom Polar policy in `omicron.polar` that defines the roles and their permissions. See `Project` or `Silo` for examples. - -6. Define an API endpoint to read and update the policy on the resource. You can use the existing examples as templates. The bulk of the implementation is in common code that's generic over the resource type so you shouldn't need to do a lot of work here. +In `omicron.polar`, custom policy defines the roles and their permissions. For Project, this includes defining the role hierarchy (admin inherits collaborator, collaborator inherits viewer) and specifying which actions each role can perform. -== Defining New Roles +API endpoints exist to read and update role assignments on the resource (`PUT` and `GET` "policy"). The implementation uses common code that's generic over the resource type, so most of the logic is shared across all resources with roles. -See <<_supporting_roles_on_resources>>. To define a new role, you'll need to add it to the shared enum type and to the database enum type. You may also need a schema migration to add the new role name. +To define a new role, you'll need to add it to the shared enum type and to the database enum type. You may also need a schema migration to add the new role name. -The enum definition drives most of the rest of the system, including the set of roles that a user is allowed to set on an object. You don't need to change any of the endpoints that modify policy. +The enum definition drives most of the rest of the system, including the set of roles that a user is allowed to set on an object. You don't need to change any of the endpoints that modify policy if you're just adding a role. From 3d8b49480527877ac6cb7dffc5bd30444b52099c Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Fri, 30 Jan 2026 10:28:09 -0800 Subject: [PATCH 11/12] fact checks and edits --- docs/authz-dev-guide.adoc | 175 +++++++++++++++++++++++++------------- 1 file changed, 116 insertions(+), 59 deletions(-) diff --git a/docs/authz-dev-guide.adoc b/docs/authz-dev-guide.adoc index 7d2f2f85232..e462937d473 100644 --- a/docs/authz-dev-guide.adoc +++ b/docs/authz-dev-guide.adoc @@ -4,9 +4,7 @@ == Overview -This document explains how to add authorization (authz) support for new resources in Nexus. Authorization is based on role-based access control (RBAC) using the Oso policy engine. - -Before implementing authz for a new resource, read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. +This document explains how to work with the authorization (authz) subsystem in Nexus. Authorization is based on role-based access control (RBAC) using the Oso policy engine. It's recommended to read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. The most important thing to know is that every authz check we have today boils down to asking: @@ -24,7 +22,7 @@ Here: - `opctx` identifies the actor. This object is ubiquitous in Nexus and constructing it requires authenticating the user. - `authz::Action` is an enum with just a handful of standard actions. -- `authz::Inventory` is the resource. It's an **authz type** (or **authz object**). +- `authz::INVENTORY` is the resource. It's an **authz object** (an instance of an **authz object**). It contains enough information about the type to figure out what authz information needed to be loaded (namely, what role information) in order to perform the check. (This is generally just its id and the ids of its parents in the resource hierarchy.) `opctx.authorize()` checks the system **policy**, which is the set of rules that produces a boolean answer to the question "can this actor perform this action on this resource?". Our policy is defined in a language called Polar in a file called `omicron.polar`. @@ -37,7 +35,7 @@ Altogether, the authz subsystem comprises: ** storage and queries for accessing role assignments in the database ** evaluation of Polar policy -=== Authz types, synthetic resources +=== Authz types Authz types are Rust types that represent **resources** on which we do authz checks. They're exported from the `authz` module. As local variables, we usually prefix them with `authz`, as in `authz_instance` to distinguish it from other representations (like `db_instance` for the database record for an instance). @@ -51,11 +49,11 @@ Synthetic resources help us **separate policy from implementation**. Suppose we opctx.authorize(authz::Action::ListChildren, &authz::IP_POOL_LIST).await; ``` -This is almost a literal translation of what we're doing: we're (listing the children) of the (IP pool list). This is by design. The question of _who is allowed to list the children of the IP pool list_ is a question of policy. That doesn't belong in the code doing the authz checks. +This is almost a literal translation of what we're doing: we're (listing the children) of the (IP pool list). This is by design. The question of "who _is_ allowed to list the children of the IP pool list" is a question of policy. That doesn't belong in the code doing the authz checks. NOTE: There's another explanation for why we have synthetic resources: it allows us to have a uniform set of just a handful of actions: `read`, `modify`, etc. The alternative would be to define a lot of different and heterogenous actions. For example, instead of having `SiloCertificateList` and `SiloUserList` on which you can `list_children`, we'd have just `Silo` on which you could `list_certificates` and `list_users`, etc. For various reasons it's been very helpful to have a small, uniform set of actions. -It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet. I'll just use `authz::FLEET` ". That's encoding the policy directly in the implementation. Instead, prefer creating a synthetic resource whose policy (in the Polar file) reflects that it's equivalent to `Fleet`, while the authz check itself remains almost a literal translation of the action you're taking. For example, rather than checking Fleet authorization when modifying inventory, we define an `Inventory` resource whose Polar policy says that Fleet admins have modify permissions on Inventory. The code just checks `authorize(Action::Modify, &INVENTORY)` without knowing anything about the Fleet. +It's tempting to take shortcuts like: "people can operate on my thing if they can operate on the Fleet. I'll just use `authz::FLEET` ". Don't! That's encoding the policy directly in the implementation. Instead, prefer creating a synthetic resource whose policy (in the Polar file) reflects that it's _equivalent_ to `Fleet`, while the authz check itself remains almost a literal translation of the action you're taking. For example, rather than checking Fleet authorization when modifying inventory, we define an `Inventory` resource whose Polar policy says that Fleet admins have modify permissions on Inventory. The code just checks `authorize(Action::Modify, &INVENTORY)` without knowing anything about the Fleet. This can sound like make-work, but this principle (separating policy from implementation) is very powerful: @@ -87,41 +85,28 @@ async fn disk_view( ==== App Layer -The details of authz at the app layer depend on the kind of resource. See the section above. - -In the end, though, you'll wind up having resolved the user-provided identifier to an `authz` object that can be used for doing authz checks. - -==== Performing Authorization Checks - -Once you have the authz type (from LookupPath, a global constant, or a hand-constructed object based on one of those), you can check authorization using something like: - -[source,rust] ----- -opctx.authorize(authz::Action::Read, &authz_disk).await?; -let disk = datastore.disk_fetch(&opctx, &authz_disk).await?; ----- - -Available actions include: `Read`, `Modify`, `Delete`, `ListChildren`, `CreateChild`. +The details of authz at the app layer depend on the kind of resource. See the section below. -Generally, authz checks should be performed as close as possible to the code that takes the action. This ensures that authz checks cannot be accidentally separated from the action they protect (leaving some code paths unchecked). For example, if you have a datastore function that modifies a resource, that function should do the authz check. More in the next section. +In the end, though, you'll wind up having resolved the user-provided identifier to an `authz` object that can be used for doing authz checks or (more often) passing to the datastore layer. ==== Datastore Layer -Datastore functions accept authz types, not raw UUIDs. This provides the context needed to perform authz checks. +Datastore functions should generally accept authz types, not raw UUIDs. This provides the context needed to perform authz checks. [source,rust] ---- impl DataStore { - pub async fn disk_fetch( + pub async fn silo_delete( &self, opctx: &OpContext, - authz_disk: &authz::Disk, - ) -> Result { - let disk_id = authz_disk.id(); - // ... query database ... + authz_silo: &authz::Silo, + // ... + ) -> DeleteResult { + opctx.authorize(authz::Action::Delete, authz_silo).await?; + // ... delete from database ... } - pub async fn disk_create( + pub async fn project_create_disk( &self, opctx: &OpContext, authz_project: &authz::Project, // Parent resource @@ -133,7 +118,20 @@ impl DataStore { } ---- -==== Key Files +==== Performing Authorization Checks + +Once you have the authz type, you can check authorization using something like: + +[source,rust] +---- +opctx.authorize(authz::Action::Delete, &authz_disk).await?; +---- + +Available actions include: `Read`, `Modify`, `Delete`, `ListChildren`, `CreateChild`. + +Authz checks can be done at any layer, but generally they should be done as close as possible to the code that takes the action. You'd expect to find the check above inside the Datastore layer, in `disk_delete`, _not_ at the app layer. This ensures that authz checks cannot be accidentally separated from the action they protect (leaving some code paths unchecked). + +=== Key Files * `nexus/auth/src/authz/mod.rs` - Overview of authz subsystem * `nexus/auth/src/authz/api_resources.rs` - Authz type definitions @@ -296,22 +294,22 @@ For dynamic resources, use `LookupPath` to look up the resource: use nexus_db_queries::db::lookup::LookupPath; // Top-level resource -let (.., authz_ip_pool) = LookupPath::new(&opctx, &datastore) - .ip_pool_id(pool_id) +let (authz_silo, _) = LookupPath::new(opctx, datastore) + .silo_name(&silo_name) .fetch() .await?; -opctx.authorize(authz::Action::Read, &authz_ip_pool).await?; +opctx.authorize(authz::Action::Read, &authz_silo).await?; // Multi-level nested resource -let (.., authz_subnet) = LookupPath::new(&opctx, &datastore) - .project_id(project_id) - .vpc_id(vpc_id) - .vpc_subnet_id(subnet_id) - .fetch() - .await?; +let (.., authz_project, authz_instance) = + LookupPath::new(opctx, datastore) + .project_name(project_name) + .instance_name(instance_name) + .lookup(authz::Action::Modify) + .await?; -opctx.authorize(authz::Action::Modify, &authz_subnet).await?; +opctx.authorize(authz::Action::Modify, &authz_instance).await?; ---- See the docs on `LookupPath` for more. @@ -577,14 +575,10 @@ For nested synthetic resources, construct the collection by hand after looking u ---- use nexus_db_queries::db::lookup::LookupPath; -let (.., authz_silo) = LookupPath::new(&opctx, &datastore) - .silo_id(silo_id) - .fetch() - .await?; +let authz_silo = // ... // Construct the collection resource manually let authz_cert_list = authz::SiloCertificateList::new(authz_silo); - opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; ---- @@ -592,7 +586,7 @@ opctx.authorize(authz::Action::CreateChild, &authz_cert_list).await?; **Roles** are constructs within our policy. We say that Fleet, Silo, and Project each have a handful of roles (like `viewer`, `collaborator`, and `admin`). Permissions in our system flow from those roles. Although the authz system supports dozens of different resources, authz checks ultimately boil down to checking whether a user has one of a few roles on these three resources. -Most of the time when you're adding a new resource, you'll define the policy for that resource in terms of these existing roles and you can ignore this whole section. +CAUTION: It is **very uncommon** for us to add a new role or add support for roles on a new resource. This is a user-facing product decision not to be taken lightly. If you're just adding a new resource, you don't need to be doing any of this. Taking Project as an example, here's how roles are implemented: @@ -605,28 +599,57 @@ The roles enum is defined in `nexus/types/src/external_api/shared.rs`: Copy, Debug, Deserialize, - Display, EnumIter, Eq, - Ord, PartialEq, - PartialOrd, Serialize, + JsonSchema, )] #[serde(rename_all = "snake_case")] pub enum ProjectRole { Admin, Collaborator, + LimitedCollaborator, Viewer, } +---- + +The enum definition drives much of the rest of the system, including the set of roles that a user is allowed to set on an object. +In `nexus/db-model/src/lib.rs`, there's an impl of `DatabaseString`: + +[source,rust] +---- impl DatabaseString for ProjectRole { - type SqlType = ProjectRoleEnum; + type Error = anyhow::Error; + + fn to_database_string(&self) -> Cow<'_, str> { + match self { + ProjectRole::Admin => "admin", + ProjectRole::Collaborator => "collaborator", + ProjectRole::LimitedCollaborator => "limited-collaborator", + ProjectRole::Viewer => "viewer", + } + .into() + } + + // WARNING: if you're considering changing this (including removing + // variants), be sure you've considered how Nexus will handle rows written + // previous to your change. + fn from_database_string(s: &str) -> Result { + match s { + "admin" => Ok(ProjectRole::Admin), + "collaborator" => Ok(ProjectRole::Collaborator), + "limited-collaborator" => Ok(ProjectRole::LimitedCollaborator), + "viewer" => Ok(ProjectRole::Viewer), + _ => { + Err(anyhow!("unsupported Project role from database: {:?}", s)) + } + } + } } ---- -There's a corresponding SQL enum type in `nexus/db-model/src/schema_versions.rs` that mirrors the Rust enum for database storage. - The authz resource is configured with `roles_allowed = true`: [source,rust] @@ -640,7 +663,44 @@ authz_resource! { } ---- -Resources with roles typically use `Custom` polar snippets since they need custom policy definitions. +Resources with roles typically use `Custom` polar snippets since they need custom policy definitions. For Project, this includes defining the role hierarchy (admin inherits collaborator, collaborator inherits viewer) and specifying which actions each role can perform. These details may be very different for different resources or roles! + +``` +resource Project { + permissions = [ + "list_children", + "modify", + "read", + "create_child", + ]; + roles = [ "admin", "collaborator", "limited-collaborator", "viewer" ]; + + # Roles implied by other roles on this resource + # Role hierarchy: admin > collaborator > limited-collaborator > viewer + # + # The "limited-collaborator" role can create/modify non-networking + # resources (instances, disks, etc.) but cannot create/modify networking + # infrastructure (VPCs, subnets, routers, internet gateways). + # See nexus/authz-macros for InProjectLimited vs InProjectFull. + "viewer" if "limited-collaborator"; + "limited-collaborator" if "collaborator"; + "collaborator" if "admin"; + + # Permissions granted directly by roles on this resource + "list_children" if "viewer"; + "read" if "viewer"; + "create_child" if "limited-collaborator"; + "modify" if "admin"; + + # Roles implied by roles on this resource's parent (Silo) + relations = { parent_silo: Silo }; + "admin" if "collaborator" on "parent_silo"; + "limited-collaborator" if "limited-collaborator" on "parent_silo"; + "viewer" if "viewer" on "parent_silo"; +} +has_relation(silo: Silo, "parent_silo", project: Project) + if project.silo = silo; +``` The `ApiResourceWithRolesType` trait is implemented to link the resource to its roles enum: @@ -651,10 +711,7 @@ impl ApiResourceWithRolesType for Project { } ---- -In `omicron.polar`, custom policy defines the roles and their permissions. For Project, this includes defining the role hierarchy (admin inherits collaborator, collaborator inherits viewer) and specifying which actions each role can perform. - -API endpoints exist to read and update role assignments on the resource (`PUT` and `GET` "policy"). The implementation uses common code that's generic over the resource type, so most of the logic is shared across all resources with roles. -To define a new role, you'll need to add it to the shared enum type and to the database enum type. You may also need a schema migration to add the new role name. +API endpoints exist to read and update role assignments on the resource: `project_policy_view` and `project_policy_update`. These are just a bit of boilerplate that call into common code that's generic over the resource type and roles. You don't need to modify these to define a new role, but you will need to add analogous API endpoints if you're adding a new resource that supports roles. -The enum definition drives most of the rest of the system, including the set of roles that a user is allowed to set on an object. You don't need to change any of the endpoints that modify policy if you're just adding a role. +To define a new role, you'll need to add it to the shared enum type and to the `DatabaseString` impl. From 06dd48aa9195bc355fe7cb6333c0d90774a11d10 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Fri, 30 Jan 2026 11:14:18 -0800 Subject: [PATCH 12/12] claude's own fact checks and edits --- docs/authz-dev-guide.adoc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/authz-dev-guide.adoc b/docs/authz-dev-guide.adoc index e462937d473..d5e03d6a141 100644 --- a/docs/authz-dev-guide.adoc +++ b/docs/authz-dev-guide.adoc @@ -4,7 +4,7 @@ == Overview -This document explains how to work with the authorization (authz) subsystem in Nexus. Authorization is based on role-based access control (RBAC) using the Oso policy engine. It's recommended to read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. +This document explains how to work with the authorization (authz) subsystem in Nexus. Authorization is based on role-based access control (RBAC) using the Oso policy engine. Read the module comments in `nexus/auth/src/authz/mod.rs` and `nexus/auth/src/authz/api_resources.rs` to understand the basic concepts. The most important thing to know is that every authz check we have today boils down to asking: @@ -22,7 +22,7 @@ Here: - `opctx` identifies the actor. This object is ubiquitous in Nexus and constructing it requires authenticating the user. - `authz::Action` is an enum with just a handful of standard actions. -- `authz::INVENTORY` is the resource. It's an **authz object** (an instance of an **authz object**). It contains enough information about the type to figure out what authz information needed to be loaded (namely, what role information) in order to perform the check. (This is generally just its id and the ids of its parents in the resource hierarchy.) +- `authz::INVENTORY` is the resource. It's an **authz object** (an instance of an **authz type**). It contains enough information about the type to figure out what authz information needed to be loaded (namely, what role information) in order to perform the check. (This is generally just its id and the ids of its parents in the resource hierarchy.) `opctx.authorize()` checks the system **policy**, which is the set of rules that produces a boolean answer to the question "can this actor perform this action on this resource?". Our policy is defined in a language called Polar in a file called `omicron.polar`. @@ -139,7 +139,7 @@ Authz checks can be done at any layer, but generally they should be done as clos * `nexus/auth/src/authz/oso_generic.rs` - Oso initialization * `nexus/authz-macros/src/lib.rs` - `authz_resource!` macro * `nexus/db-queries/src/policy_test/resources.rs` - Policy test setup -* `nexus/db-queries/src/db/lookup.rs` - LookupPath implementation +* `nexus/db-lookup/src/lookup_path.rs` - LookupPath implementation * `nexus/types/src/external_api/shared.rs` - Role enum definitions == Adding New Authz Resources @@ -291,7 +291,7 @@ For dynamic resources, use `LookupPath` to look up the resource: [source,rust] ---- -use nexus_db_queries::db::lookup::LookupPath; +use nexus_db_lookup::LookupPath; // Top-level resource let (authz_silo, _) = LookupPath::new(opctx, datastore) @@ -573,7 +573,7 @@ For nested synthetic resources, construct the collection by hand after looking u [source,rust] ---- -use nexus_db_queries::db::lookup::LookupPath; +use nexus_db_lookup::LookupPath; let authz_silo = // ...