diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 3e1d553f999..23c98529fab 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -273,6 +273,7 @@ CREATE TABLE omicron.public.organization ( ); CREATE UNIQUE INDEX ON omicron.public.organization ( + silo_id, name ) WHERE time_deleted IS NULL; diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 7393c0eb58f..e97371ad38e 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -47,6 +47,7 @@ serde_with = "1.13.0" sled-agent-client = { path = "../sled-agent-client" } slog-dtrace = "0.2" structopt = "0.3" +strum = { version = "0.23", features = [ "derive" ] } tempfile = "3.3" thiserror = "1.0" toml = "0.5.9" @@ -127,7 +128,6 @@ regex = "1.5.5" subprocess = "0.2.9" term = "0.7" httptest = "0.15.4" -strum = { version = "0.23", features = [ "derive" ] } [dev-dependencies.openapi-lint] git = "https://github.com/oxidecomputer/openapi-lint" diff --git a/nexus/src/app/project.rs b/nexus/src/app/project.rs index e63a14ecf10..e8b50de9ee4 100644 --- a/nexus/src/app/project.rs +++ b/nexus/src/app/project.rs @@ -96,7 +96,7 @@ impl super::Nexus { ) -> ListResultVec { let (.., authz_org) = LookupPath::new(opctx, &self.db_datastore) .organization_name(organization_name) - .lookup_for(authz::Action::CreateChild) + .lookup_for(authz::Action::ListChildren) .await?; self.db_datastore .projects_list_by_name(opctx, &authz_org, pagparams) @@ -111,7 +111,7 @@ impl super::Nexus { ) -> ListResultVec { let (.., authz_org) = LookupPath::new(opctx, &self.db_datastore) .organization_name(organization_name) - .lookup_for(authz::Action::CreateChild) + .lookup_for(authz::Action::ListChildren) .await?; self.db_datastore .projects_list_by_id(opctx, &authz_org, pagparams) diff --git a/nexus/src/authz/api_resources.rs b/nexus/src/authz/api_resources.rs index 915b98b19b5..3ef2b6d0412 100644 --- a/nexus/src/authz/api_resources.rs +++ b/nexus/src/authz/api_resources.rs @@ -50,7 +50,6 @@ use parse_display::Display; use parse_display::FromStr; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[cfg(test)] use strum::EnumIter; use uuid::Uuid; @@ -206,9 +205,16 @@ impl ApiResourceWithRolesType for Fleet { } #[derive( - Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, JsonSchema, + Clone, + Copy, + Debug, + Deserialize, + EnumIter, + Eq, + PartialEq, + Serialize, + JsonSchema, )] -#[cfg_attr(test, derive(EnumIter))] #[serde(rename_all = "snake_case")] pub enum FleetRoles { Admin, @@ -379,13 +385,13 @@ impl ApiResourceWithRolesType for Organization { Debug, Deserialize, Display, + EnumIter, Eq, FromStr, PartialEq, Serialize, JsonSchema, )] -#[cfg_attr(test, derive(EnumIter))] #[display(style = "kebab-case")] #[serde(rename_all = "snake_case")] pub enum OrganizationRoles { @@ -433,13 +439,13 @@ impl ApiResourceWithRolesType for Project { Debug, Deserialize, Display, + EnumIter, Eq, FromStr, PartialEq, Serialize, JsonSchema, )] -#[cfg_attr(test, derive(EnumIter))] #[display(style = "kebab-case")] #[serde(rename_all = "snake_case")] pub enum ProjectRoles { @@ -579,13 +585,13 @@ impl ApiResourceWithRolesType for Silo { Debug, Deserialize, Display, + EnumIter, Eq, FromStr, PartialEq, Serialize, JsonSchema, )] -#[cfg_attr(test, derive(EnumIter))] #[display(style = "kebab-case")] #[serde(rename_all = "snake_case")] pub enum SiloRoles { diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index 9de39b4522d..fe8112b86cf 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -137,23 +137,42 @@ resource Silo { ]; roles = [ "admin", "collaborator", "viewer" ]; + # permissions granted by this resource's roles "list_children" if "viewer"; "read" if "viewer"; + "create_child" if "collaborator"; + "modify" if "admin"; + # roles implied by other roles "viewer" if "collaborator"; - "create_child" if "collaborator"; "collaborator" if "admin"; - "modify" if "admin"; + + # roles implied by relationships with the parent fleet relations = { parent_fleet: Fleet }; - "admin" if "admin" on "parent_fleet"; - "collaborator" if "collaborator" on "parent_fleet"; + "admin" if "collaborator" on "parent_fleet"; "viewer" if "viewer" on "parent_fleet"; + "list_children" if "viewer" on "parent_fleet"; } has_relation(fleet: Fleet, "parent_fleet", silo: Silo) if silo.fleet = fleet; -has_role(actor: AuthenticatedActor, "viewer", silo: Silo) + +# All authenticated users can read their own Silo. That's not quite the same as +# having the "viewer" role. For example, they cannot list Organizations in the +# Silo. +# +# One reason this is necessary is because if an unprivileged user tries to +# create an Organization using "POST /organizations", they should get back a 403 +# (which implies they're able to see /organizations, which is essentially seeing +# the Silo itself) rather than a 404. This behavior isn't a hard constraint +# (i.e., you could reasonably get a 404 for an API you're not allowed to call). +# Nor is the implementation (i.e., we could special-case this endpoint somehow). +# But granting this permission is the simplest way to keep this endpoint's +# behavior consistent with the rest of the API. +# +# It's unclear what else would break if users couldn't see their own Silo. +has_permission(actor: AuthenticatedActor, "read", silo: Silo) # TODO-security TODO-coverage We should have a test that exercises this - # case. + # syntax. if silo in actor.silo; resource Organization { @@ -180,7 +199,9 @@ resource Organization { "modify" if "admin"; relations = { parent_silo: Silo }; - "admin" if "admin" on "parent_silo"; + "admin" if "collaborator" on "parent_silo"; + "read" if "viewer" on "parent_silo"; + "list_children" if "viewer" on "parent_silo"; } has_relation(silo: Silo, "parent_silo", organization: Organization) if organization.silo = silo; @@ -212,7 +233,9 @@ resource Project { "modify" if "admin"; relations = { parent_organization: Organization }; - "admin" if "admin" on "parent_organization"; + "admin" if "collaborator" on "parent_organization"; + + "viewer" if "list_children" on "parent_organization"; } has_relation(organization: Organization, "parent_organization", project: Project) if project.organization = organization; diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 02f53ad2f00..c80d3c81e89 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -676,7 +676,7 @@ impl DataStore { pagparams: &DataPageParams<'_, Name>, ) -> ListResultVec { let authz_silo = opctx.authn.silo_required()?; - opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + opctx.authorize(authz::Action::ListChildren, &authz_silo).await?; use db::schema::organization::dsl; paginated(dsl::organization, dsl::name, pagparams) diff --git a/nexus/tests/integration_tests/authz_roles.rs b/nexus/tests/integration_tests/authz_roles.rs new file mode 100644 index 00000000000..6e8e299ccd8 --- /dev/null +++ b/nexus/tests/integration_tests/authz_roles.rs @@ -0,0 +1,1185 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! (Fairly) comprehensive test of authz by creating a hierarchy of resources +//! and a group of users with various roles on these resources and verifying +//! that each role grants the privileges we expect. + +// XXX-dap TODO +// - implement tests for DELETE. One idea: +// - every resource has a "recreate()" function that recreates it (including +// the role assignments) +// - this list is guaranteed to be topo-sorted +// - we explicitly delete every resource when we're done testing it +// +// Then we can test these in reverse order. By the time we get to any +// resource, its children should be gone. +// - figure out how to implement tests for modifying the policy. Maybe +// have a dummy user that we grant/remove privileges for? +// - do we want to look into performance at all? It takes a long time. +// - this test needs a lot of documentation +// - document limitations / figure out how to get better coverage. What this +// test really verifies is that the roles probably grant what we expect, and +// that the specific APIs tested check the privileges we expect. It doesn't +// test that any other APIs check the right privileges. That's a lot harder +// -- we basically need a whole coverage test version of this (which seems +// harder than the "unauthorized" test). Alternatively, we could modify the +// basic functionality tests to use minimum-privileged users, which would +// ensure that those minimum privileges are _sufficient_ to make the +// corresponding API calls. But that wouldn't test that _other_ privileges +// _don't_ grant those permissions. That seems a lot harder. + +use anyhow::anyhow; +use dropshot::test_util::ClientTestContext; +use http::Method; +use http::StatusCode; +use lazy_static::lazy_static; +use nexus_test_utils::http_testing::AuthnMode; +use nexus_test_utils::http_testing::NexusRequest; +use nexus_test_utils::http_testing::RequestBuilder; +use nexus_test_utils::resource_helpers; +use nexus_test_utils::ControlPlaneTestContext; +use nexus_test_utils_macros::nexus_test; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::IdentityMetadataUpdateParams; +use omicron_common::api::external::Ipv4Net; +use omicron_common::api::external::Name; +use omicron_nexus::app::test_interfaces::TestInterfaces; +use omicron_nexus::authz; +use omicron_nexus::external_api::params; +use omicron_nexus::external_api::shared; +use omicron_nexus::external_api::shared::IdentityType; +use std::collections::BTreeMap; +use std::convert::AsRef; +use std::io::Write; +use strum::AsRefStr; +use uuid::Uuid; + +struct World { + users: BTreeMap<(String, String), Uuid>, + resources: Vec<&'static Resource>, +} + +#[nexus_test] +async fn test_authz_roles(cptestctx: &ControlPlaneTestContext) { + let world = setup_hierarchy(cptestctx).await; + + // For each user that we've created, for each resource, for each possible + // action, attempt the action. + let mut cursor = std::io::Cursor::new(Vec::new()); + { + let mut stdout = std::io::stdout(); + let mut stream = DumbTee::new(vec![&mut cursor, &mut stdout]); + test_all_operations(cptestctx, &world, &mut stream) + .await + .expect("failed to write output"); + } + + // Some general notes if you're debugging a failure in this test: + // + // Fleet-level roles apply to everything in the system. However, even fleet + // admins have no way to refer to resources in Silos other than the ones + // they exist in. This test creates fleet admins in Silo "s1". So they can + // do all the usual things on the resources in "s1", but not in "s2". + // + // Administrators on "s2" appear to be able to list and create Organizations + // in Silo "s1" in the test's output. But that just means they can list and + // create things under /organizations -- that's listing and creating + // Organizations in "s2", not "s1". + let output = cursor.into_inner(); + expectorate::assert_contents( + "tests/output/authz-roles-test.txt", + &String::from_utf8_lossy(&output), + ); +} + +#[derive(Debug)] +struct Resource { + resource_type: ResourceType, +} + +impl Resource { + fn full_name(&self) -> String { + match self.resource_type { + ResourceType::Fleet => String::from("fleet"), + ResourceType::Silo { name } => format!("{}", name), + ResourceType::Organization { name, parent_silo } => { + format!("{}{}", parent_silo, name) + } + ResourceType::Project { name, parent_org, parent_silo } => { + format!("{}{}{}", parent_silo, parent_org, name) + } + ResourceType::Vpc { + name, + parent_project, + parent_org, + parent_silo, + } => format!( + "{}{}{}{}", + parent_silo, parent_org, parent_project, name + ), + } + } + + fn parent_full_name(&self) -> String { + match self.resource_type { + ResourceType::Fleet => unimplemented!(), + ResourceType::Silo { .. } => unimplemented!(), + ResourceType::Organization { parent_silo, .. } => { + format!("{}", parent_silo) + } + ResourceType::Project { parent_org, parent_silo, .. } => { + format!("{}{}", parent_silo, parent_org) + } + ResourceType::Vpc { + parent_project, + parent_org, + parent_silo, + .. + } => format!("{}{}{}", parent_silo, parent_org, parent_project), + } + } + + fn create_url(&self) -> String { + match self.resource_type { + ResourceType::Fleet => unimplemented!(), + ResourceType::Silo { .. } => format!("/silos"), + ResourceType::Organization { .. } => { + format!("/organizations") + } + ResourceType::Project { parent_org, .. } => { + format!("/organizations/{}/projects", parent_org) + } + ResourceType::Vpc { parent_project, parent_org, .. } => { + format!( + "/organizations/{}/projects/{}/vpcs", + parent_org, parent_project + ) + } + } + } + + fn policy_url(&self) -> String { + match self.resource_type { + ResourceType::Fleet => format!("/policy"), + ResourceType::Silo { name } => format!("/silos/{}/policy", name), + ResourceType::Organization { name, .. } => { + format!("/organizations/{}/policy", name) + } + ResourceType::Project { name, parent_org, .. } => format!( + "/organizations/{}/projects/{}/policy", + parent_org, name + ), + ResourceType::Vpc { .. } => unimplemented!(), + } + } + + fn test_operations<'a>( + &self, + client: &'a ClientTestContext, + username: &str, + ) -> Vec> { + match self.resource_type { + ResourceType::Fleet => { + let silos_url = "/silos"; + let new_silo_name = username.parse().expect( + "invalid test Silo name (tried to use a username \ + that we generated)", + ); + let new_silo_description = format!("created by {}", username); + let silo_url = format!("{}/{}", &silos_url, new_silo_name); + vec![ + TestOperation { + label: "FetchPolicy", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &self.policy_url() + )), + on_success: None, + }, + TestOperation { + label: "ListSagas", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + "/sagas", + )), + on_success: None, + }, + TestOperation { + label: "ListSleds", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + "/hardware/sleds", + )), + on_success: None, + }, + TestOperation { + label: "ListRacks", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + "/hardware/racks", + )), + on_success: None, + }, + TestOperation { + label: "ListBuiltinUsers", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + "/users", + )), + on_success: None, + }, + TestOperation { + label: "ListBuiltinRoles", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + "/roles", + )), + on_success: None, + }, + TestOperation { + label: "ListSilos", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + "/silos", + )), + on_success: None, + }, + TestOperation { + label: "CreateSilo", + template: NexusRequest::new( + RequestBuilder::new(client, Method::POST, "/silos") + .body(Some(¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: new_silo_name, + description: new_silo_description, + }, + discoverable: true, + })), + ), + on_success: Some(NexusRequest::object_delete( + client, &silo_url, + )), + }, + ] + } + + ResourceType::Silo { name } => { + let resource_url = format!("{}/{}", self.create_url(), name); + let orgs_url = "/organizations"; + let new_org_name = username.parse().expect( + "invalid test organization name (tried to use a username \ + that we generated)", + ); + let new_org_description = format!("created by {}", username); + let org_url = format!("{}/{}", orgs_url, new_org_name); + vec![ + TestOperation { + label: "Fetch", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &resource_url, + )), + on_success: None, + }, + TestOperation { + label: "FetchPolicy", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &self.policy_url() + )), + on_success: None, + }, + TestOperation { + label: "ListOrganizations", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + orgs_url, + )), + on_success: None, + }, + TestOperation { + label: "CreateOrganization", + template: NexusRequest::new( + RequestBuilder::new(client, Method::POST, orgs_url) + .body(Some(¶ms::OrganizationCreate { + identity: IdentityMetadataCreateParams { + name: new_org_name, + description: new_org_description, + }, + })), + ), + on_success: Some(NexusRequest::object_delete( + client, &org_url, + )), + }, + ] + } + + ResourceType::Organization { name, .. } => { + let resource_url = format!("{}/{}", self.create_url(), name); + let projects_url = format!("{}/projects", &resource_url); + let new_project_name = username.parse().expect( + "invalid test project name (tried to use a username \ + that we generated)", + ); + let new_project_description = + format!("created by {}", username); + let project_url = + format!("{}/{}", &projects_url, new_project_name); + vec![ + TestOperation { + label: "Fetch", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &resource_url, + )), + on_success: None, + }, + TestOperation { + label: "FetchPolicy", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &self.policy_url() + )), + on_success: None, + }, + TestOperation { + label: "ListProjects", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &projects_url, + )), + on_success: None, + }, + TestOperation { + label: "CreateProject", + template: NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &projects_url, + ) + .body(Some( + ¶ms::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: new_project_name, + description: new_project_description, + }, + }, + )), + ), + on_success: Some(NexusRequest::object_delete( + client, + &project_url, + )), + }, + TestOperation { + label: "ModifyDescription", + template: NexusRequest::new( + RequestBuilder::new( + client, + Method::PUT, + &resource_url, + ) + .body(Some( + ¶ms::OrganizationUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some(String::from( + "updated!", + )), + }, + }, + )), + ), + on_success: None, + }, + ] + } + + ResourceType::Project { name, .. } => { + let resource_url = format!("{}/{}", self.create_url(), name); + let vpcs_url = format!("{}/vpcs", &resource_url); + let new_vpc_name: Name = username.parse().expect( + "invalid test VPC name (tried to use a username \ + that we generated)", + ); + let new_vpc_description = format!("created by {}", username); + let vpc_url = format!("{}/{}", &vpcs_url, new_vpc_name); + vec![ + TestOperation { + label: "Fetch", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &resource_url, + )), + on_success: None, + }, + TestOperation { + label: "FetchPolicy", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &self.policy_url() + )), + on_success: None, + }, + TestOperation { + label: "ListVpcs", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &vpcs_url, + )), + on_success: None, + }, + TestOperation { + label: "CreateVpc", + template: NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &vpcs_url, + ) + .body(Some( + ¶ms::VpcCreate { + identity: IdentityMetadataCreateParams { + name: new_vpc_name.clone(), + description: new_vpc_description, + }, + ipv6_prefix: None, + dns_name: new_vpc_name, + }, + )), + ), + on_success: Some(NexusRequest::object_delete( + client, &vpc_url, + )), + }, + TestOperation { + label: "ModifyDescription", + template: NexusRequest::new( + RequestBuilder::new( + client, + Method::PUT, + &resource_url, + ) + .body(Some( + ¶ms::ProjectUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some(String::from( + "updated!", + )), + }, + }, + )), + ), + on_success: None, + }, + ] + } + + ResourceType::Vpc { name, .. } => { + let resource_url = format!("{}/{}", self.create_url(), name); + let subnets_url = format!("{}/subnets", &resource_url); + let new_subnet_name: Name = username.parse().expect( + "invalid test VPC name (tried to use a username \ + that we generated)", + ); + let new_subnet_description = format!("created by {}", username); + let subnet_url = + format!("{}/{}", &subnets_url, new_subnet_name); + + vec![ + TestOperation { + label: "Fetch", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &resource_url, + )), + on_success: None, + }, + TestOperation { + label: "FetchPolicy", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &self.policy_url() + )), + on_success: None, + }, + TestOperation { + label: "ListSubnets", + template: NexusRequest::new(RequestBuilder::new( + client, + Method::GET, + &subnets_url, + )), + on_success: None, + }, + TestOperation { + label: "CreateSubnet", + template: NexusRequest::new( + RequestBuilder::new( + client, + Method::POST, + &subnets_url, + ) + .body(Some( + ¶ms::VpcSubnetCreate { + identity: IdentityMetadataCreateParams { + name: new_subnet_name.clone(), + description: new_subnet_description, + }, + ipv4_block: Ipv4Net( + "192.168.1.0/24".parse().unwrap(), + ), + ipv6_block: None, + }, + )), + ), + on_success: Some(NexusRequest::object_delete( + client, + &subnet_url, + )), + }, + TestOperation { + label: "ModifyDescription", + template: NexusRequest::new( + RequestBuilder::new( + client, + Method::PUT, + &resource_url, + ) + .body(Some( + ¶ms::VpcUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some(String::from( + "updated!", + )), + }, + dns_name: None, + }, + )), + ), + on_success: None, + }, + ] + } + } + } +} + +#[derive(Debug, AsRefStr)] +enum ResourceType { + Fleet, + Silo { + name: &'static str, + }, + Organization { + name: &'static str, + parent_silo: &'static str, + }, + Project { + name: &'static str, + parent_org: &'static str, + parent_silo: &'static str, + }, + Vpc { + name: &'static str, + parent_project: &'static str, + parent_org: &'static str, + parent_silo: &'static str, + }, +} + +lazy_static! { + // Hierarchy: + // fleet + // fleet/s1 + // fleet/s1/o1 + // fleet/s1/o1/p1 + // fleet/s1/o1/p1/i1 + // fleet/s1/o1/p2 + // fleet/s1/o1/p2/i1 + // fleet/s1/o2 + // fleet/s1/o2/p1 + // fleet/s1/o2/p1/i1 + // fleet/s2 + // fleet/s2/o3 + // fleet/s2/o3/p1 + // fleet/s2/o3/p1/i1 + // + // Silo 2's organization is called "o3" because otherwise it appears as + // though users from Silo 1 are successfully accessing it. + // TODO-security TODO-coverage when we add support for looking up resources + // by id, we should update this test to operate on things by-id as well. + + static ref FLEET: Resource = Resource { resource_type: ResourceType::Fleet }; + + static ref SILO1: Resource = + Resource { resource_type: ResourceType::Silo { name: "s1" } }; + static ref SILO1_ORG1: Resource = Resource { + resource_type: ResourceType::Organization { + name: "o1", + parent_silo: "s1", + }, + }; + static ref SILO1_ORG1_PROJ1: Resource = Resource { + resource_type: ResourceType::Project { + name: "p1", + parent_org: "o1", + parent_silo: "s1", + }, + }; + static ref SILO1_ORG1_PROJ1_INST: Resource = Resource { + resource_type: ResourceType::Vpc { + name: "v1", + parent_project: "p1", + parent_org: "o1", + parent_silo: "s1", + }, + }; + static ref SILO1_ORG1_PROJ2: Resource = Resource { + resource_type: ResourceType::Project { + name: "p2", + parent_org: "o1", + parent_silo: "s1", + }, + }; + static ref SILO1_ORG1_PROJ2_INST: Resource = Resource { + resource_type: ResourceType::Vpc { + name: "v1", + parent_project: "p2", + parent_org: "o1", + parent_silo: "s1", + }, + }; + static ref SILO1_ORG2: Resource = Resource { + resource_type: ResourceType::Organization { + name: "o2", + parent_silo: "s1", + }, + }; + static ref SILO1_ORG2_PROJ1: Resource = Resource { + resource_type: ResourceType::Project { + name: "p1", + parent_org: "o2", + parent_silo: "s1", + }, + }; + static ref SILO1_ORG2_PROJ1_INST: Resource = Resource { + resource_type: ResourceType::Vpc { + name: "v1", + parent_project: "p1", + parent_org: "o2", + parent_silo: "s1", + }, + }; + + static ref SILO2: Resource = + Resource { resource_type: ResourceType::Silo { name: "s2" } }; + static ref SILO2_ORG3: Resource = Resource { + resource_type: ResourceType::Organization { + name: "o3", + parent_silo: "s2", + }, + }; + static ref SILO2_ORG3_PROJ1: Resource = Resource { + resource_type: ResourceType::Project { + name: "p1", + parent_org: "o3", + parent_silo: "s2", + }, + }; + static ref SILO2_ORG3_PROJ1_INST: Resource = Resource { + resource_type: ResourceType::Vpc { + name: "v1", + parent_project: "p1", + parent_org: "o3", + parent_silo: "s2", + }, + }; + + // XXX-dap TODO-doc These are the resources for which: for each role, we + // will create a user having that role on this resource. We will later test + // that this user _can_ access the corresponding thing in the corresponding + // way, and that they _cannot_ access any _other_ resources in any other + // way. + // Silos are skipped because we always create their users. + static ref RESOURCES_WITH_USERS: Vec<&'static Resource> = vec![ + &*FLEET, + &*SILO1_ORG1, + &*SILO1_ORG1_PROJ1, + ]; + + static ref ALL_RESOURCES: Vec<&'static Resource> = vec![ + &*FLEET, + &*SILO1, + &*SILO1_ORG1, + &*SILO1_ORG1_PROJ1, + &*SILO1_ORG1_PROJ1_INST, + &*SILO1_ORG1_PROJ2, + &*SILO1_ORG1_PROJ2_INST, + &*SILO1_ORG2, + &*SILO1_ORG2_PROJ1, + &*SILO1_ORG2_PROJ1_INST, + &*SILO2, + &*SILO2_ORG3, + &*SILO2_ORG3_PROJ1, + &*SILO2_ORG3_PROJ1_INST, + ]; +} + +async fn setup_hierarchy(testctx: &ControlPlaneTestContext) -> World { + let client = &testctx.external_client; + let nexus = &*testctx.server.apictx.nexus; + let log = &testctx.logctx.log.new(o!("component" => "SetupHierarchy")); + + // Mapping: (our resource name, role name) -> user id + // where "user id" is the id of a user having role "role name" on resource + // "our resource name". + // + // "our resource name" is a string like s1o1 (SILO1_ORG1). + let mut users: BTreeMap<(String, String), Uuid> = BTreeMap::new(); + + // Mapping: silo name -> silo id + let mut silos: BTreeMap = BTreeMap::new(); + + // We can't use the helpers in `resource_helpers` to create most of these + // resources because they always use the "test-privileged" user in the + // default Silo. But in order to create things in other Silos, we need to + // use different users. + for resource in &*ALL_RESOURCES { + let resource = *resource; + println!("creating resource: {:?}", resource); + debug!(log, "creating resource"; "resource" => ?resource); + match resource.resource_type { + ResourceType::Fleet => (), + ResourceType::Silo { name } => { + let silo = + resource_helpers::create_silo(client, name, false).await; + silos.insert(name.to_string(), silo.identity.id); + // We have to create the Silo users here so that we can use the + // admin to create the other resources. + create_users::( + log, + nexus, + client, + name, + silo.identity.id, + &resource.policy_url(), + &mut users, + None, + ) + .await; + } + ResourceType::Organization { name, parent_silo, .. } => { + let caller_id = user_id(&users, &parent_silo, "admin"); + let full_name = resource.full_name(); + NexusRequest::objects_post( + client, + &resource.create_url(), + ¶ms::OrganizationCreate { + identity: IdentityMetadataCreateParams { + name: name + .parse() + .expect("generated name was invalid"), + description: full_name.clone(), + }, + }, + ) + .authn_as(AuthnMode::SiloUser(caller_id)) + .execute() + .await + .unwrap_or_else(|_| panic!("failed to create {}", full_name)); + } + ResourceType::Project { name, parent_silo, .. } => { + let caller_id = user_id(&users, &parent_silo, "admin"); + let full_name = resource.full_name(); + NexusRequest::objects_post( + client, + &resource.create_url(), + ¶ms::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: name + .parse() + .expect("generated name was invalid"), + description: full_name.clone(), + }, + }, + ) + .authn_as(AuthnMode::SiloUser(caller_id)) + .execute() + .await + .unwrap_or_else(|_| panic!("failed to create {}", full_name)); + } + ResourceType::Vpc { name, parent_silo, .. } => { + let caller_id = user_id(&users, &parent_silo, "admin"); + let full_name = resource.full_name(); + NexusRequest::objects_post( + client, + &resource.create_url(), + ¶ms::VpcCreate { + identity: IdentityMetadataCreateParams { + name: name + .parse() + .expect("generated name was invalid"), + description: full_name.clone(), + }, + ipv6_prefix: None, + dns_name: full_name.parse().expect( + "expected resource name to be a valid DNS name", + ), + }, + ) + .authn_as(AuthnMode::SiloUser(caller_id)) + .execute() + .await + .unwrap_or_else(|_| panic!("failed to create {}", full_name)); + } + } + } + + for resource in &*RESOURCES_WITH_USERS { + match resource.resource_type { + // We don't create users for Vpcs. We already created users for + // Silos. + ResourceType::Vpc { .. } | ResourceType::Silo { .. } => { + unimplemented!() + } + ResourceType::Fleet => { + let silo_id = silos.get("s1").expect("missing silo \"s1\""); + create_users::( + log, + nexus, + client, + &resource.full_name(), + *silo_id, + &resource.policy_url(), + &mut users, + None, + ) + .await; + } + ResourceType::Organization { parent_silo, .. } => { + let silo_id = silos.get(parent_silo).expect("missing silo"); + let caller_id = + user_id(&users, &resource.parent_full_name(), "admin"); + create_users::( + log, + nexus, + client, + &resource.full_name(), + *silo_id, + &resource.policy_url(), + &mut users, + Some(caller_id), + ) + .await; + } + ResourceType::Project { parent_silo, .. } => { + let silo_id = silos.get(parent_silo).expect("missing silo"); + let caller_id = + user_id(&users, &resource.parent_full_name(), "admin"); + create_users::( + log, + nexus, + client, + &resource.full_name(), + *silo_id, + &resource.policy_url(), + &mut users, + Some(caller_id), + ) + .await; + } + } + } + + println!("setup done\n"); + World { users, resources: ALL_RESOURCES.clone() } +} + +fn user_id( + users: &BTreeMap<(String, String), Uuid>, + resource_full_name: &str, + role_name: &str, +) -> Uuid { + *users + .get(&(resource_full_name.to_owned(), role_name.to_owned())) + .unwrap_or_else(|| { + panic!( + "expected user to be created with role {:?} on {:?}", + role_name, resource_full_name, + ) + }) +} + +/// For a given resource, for each supported role, create a new user with that +/// role on this resource. +/// +/// - `nexus`: needed to use the private interface for creating silo users +/// - `resource_name`: *our* identifier for the resource (e.g., s1o1). This is +/// used as the basename of the user's name +/// - `policy_url`: the URL for the resource's IAM policy +/// - `users`: a map into which we'll insert the uuids of created users +/// - `run_as`: executes requests to update resource policy using the given silo +/// user id. This lets us, say, create an Organization with a user having +/// "admin" on the parent Silo, and create a Project with a user having +/// "admin" on the parent Organization, etc. +async fn create_users< + T: strum::IntoEnumIterator + + serde::Serialize + + serde::de::DeserializeOwned + + omicron_nexus::db::model::DatabaseString, +>( + log: &slog::Logger, + nexus: &dyn TestInterfaces, + client: &ClientTestContext, + resource_name: &str, + silo_id: Uuid, + policy_url: &str, + users: &mut BTreeMap<(String, String), Uuid>, + run_as: Option, +) { + for variant in T::iter() { + let role_name = variant.to_database_string(); + // TODO when silo users get user names, this should go into it. + let username = format!("{}-{}", resource_name, role_name); + let user_id = Uuid::new_v4(); + println!("creating user: {}", &username); + debug!( + log, + "creating user"; + "username" => &username, + "user_id" => user_id.to_string() + ); + nexus + .silo_user_create(silo_id, user_id) + .await + .unwrap_or_else(|_| panic!("failed to create user {:?}", username)); + users.insert((resource_name.to_owned(), role_name.to_owned()), user_id); + + println!("adding role {} for user {}", role_name, &username); + debug!( + log, + "adding role"; + "username" => username, + "user_id" => user_id.to_string(), + "role" => role_name, + ); + let authn_mode = run_as + .map(|caller_id| AuthnMode::SiloUser(caller_id)) + .unwrap_or(AuthnMode::PrivilegedUser); + + let existing_policy: shared::Policy = + NexusRequest::object_get(client, policy_url) + .authn_as(authn_mode.clone()) + .execute() + .await + .expect("failed to fetch policy") + .parsed_body() + .expect("failed to parse policy"); + + let new_role_assignment = shared::RoleAssignment { + identity_type: IdentityType::SiloUser, + identity_id: user_id, + role_name: variant, + }; + let new_role_assignments = existing_policy + .role_assignments + .into_iter() + .chain(std::iter::once(new_role_assignment)) + .collect(); + + let new_policy = + shared::Policy { role_assignments: new_role_assignments }; + + NexusRequest::object_put(client, policy_url, Some(&new_policy)) + .authn_as(authn_mode) + .execute() + .await + .expect("failed to update policy"); + } +} + +struct TestOperation<'a> { + label: &'static str, + template: NexusRequest<'a>, + on_success: Option>, +} + +enum OperationResult { + Success, + Denied, + UnexpectedError(anyhow::Error), +} + +async fn test_all_operations( + cptestctx: &ControlPlaneTestContext, + world: &World, + mut out: W, +) -> std::io::Result<()> { + let log = &cptestctx.logctx.log; + let client = &cptestctx.external_client; + for resource in &world.resources { + write!( + out, + "resource: {} {:?}\n", + resource.resource_type.as_ref(), + resource.full_name() + )?; + + let mut first = true; + for ((user_resource_name, user_role_name), user_id) in &world.users { + let username = format!("{}-{}", user_resource_name, user_role_name); + let log = log.new(o!( + "resource" => resource.full_name(), + "user" => username.clone(), + )); + + let operations = resource.test_operations(client, &username); + if first { + let test_labels = + operations.iter().map(|t| t.label).collect::>(); + write!(out, " actions: {}\n", test_labels.join(", "))?; + write!(out, "{:20} {}\n", "USER", "RESULTS FOR EACH ACTION")?; + first = false; + } + + write!(out, "{:20}", username)?; + for test_operation in operations.into_iter() { + let op_result = + run_test_operation(&log, test_operation, *user_id).await; + write!( + out, + " {}", + match op_result { + OperationResult::Success => '\u{2713}', + OperationResult::Denied => '\u{2717}', + OperationResult::UnexpectedError(_) => '\u{26a0}', + } + )?; + } + + write!(out, "\n")?; + } + + write!(out, "\n")?; + } + + Ok(()) +} + +async fn run_test_operation<'a>( + log: &slog::Logger, + to: TestOperation<'a>, + user_id: Uuid, +) -> OperationResult { + let log = log.new(o!("operation" => to.label)); + trace!(log, "test operation"); + let request = to.template; + let response = request + .authn_as(AuthnMode::SiloUser(user_id)) + .execute() + .await + .expect("failed to execute request"); + let req_id = response + .headers + .get(dropshot::HEADER_REQUEST_ID) + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let log = log.new(o!( + "status_code" => response.status.to_string(), + "req_id" => req_id, + )); + if matches!(response.status, StatusCode::NOT_FOUND | StatusCode::FORBIDDEN) + { + info!(log, "test operation result"; "result" => "denied"); + OperationResult::Denied + } else if response.status.is_success() { + info!(log, "test operation result"; "result" => "success"); + if let Some(request) = to.on_success { + debug!(log, "on_success operation"); + request + .authn_as(AuthnMode::SiloUser(user_id)) + .execute() + .await + .expect("failed to execute on-success request"); + } + + OperationResult::Success + } else { + let status = response.status; + let error_response: dropshot::HttpErrorResponseBody = + response.parsed_body().expect("failed to parse error response"); + info!( + log, + "test operation result"; + "result" => "unexpected failure", + "message" => error_response.message.clone(), + ); + OperationResult::UnexpectedError(anyhow!( + "unexpected response: status code {}, message {:?}", + status, + error_response.message + )) + } +} + +struct DumbTee<'a> { + sinks: Vec<&'a mut dyn Write>, +} + +impl<'a> DumbTee<'a> { + fn new(sinks: Vec<&mut dyn Write>) -> DumbTee { + DumbTee { sinks } + } +} + +impl<'a> Write for DumbTee<'a> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + for sink in &mut self.sinks { + let size = sink + .write(buf) + .expect("one side of the tee failed unexpectedly"); + assert_eq!( + size, + buf.len(), + "tee can only be used with streams that always accept all data" + ); + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + for sink in &mut self.sinks { + sink.flush().expect("one side of the tee failed to flush"); + } + + Ok(()) + } +} diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index de5de9679bd..fb1e53447e1 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -4,6 +4,7 @@ //! the way it is. mod authn_http; +mod authz_roles; mod basic; mod commands; mod console_api; diff --git a/nexus/tests/output/authz-roles-test.txt b/nexus/tests/output/authz-roles-test.txt new file mode 100644 index 00000000000..7bae2e5d80d --- /dev/null +++ b/nexus/tests/output/authz-roles-test.txt @@ -0,0 +1,252 @@ +resource: Fleet "fleet" + actions: FetchPolicy, ListSagas, ListSleds, ListRacks, ListBuiltinUsers, ListBuiltinRoles, ListSilos, CreateSilo +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✗ +s1-admin ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s1-collaborator ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s1-viewer ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s1o1-admin ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ + +resource: Silo "s1" + actions: Fetch, ListOrganizations, CreateOrganization +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ +s1-admin ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ +s1o1-admin ✓ ✗ ✗ +s1o1-collaborator ✓ ✗ ✗ +s1o1p1-admin ✓ ✗ ✗ +s1o1p1-collaborator ✓ ✗ ✗ +s1o1p1-viewer ✓ ✗ ✗ +s2-admin ✗ ✓ ✓ +s2-collaborator ✗ ✓ ✓ +s2-viewer ✗ ✓ ✗ + +resource: Organization "s1o1" + actions: Fetch, ListProjects, CreateProject, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✓ ✓ ✓ ✓ +s1o1-collaborator ✓ ✓ ✓ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Project "s1o1p1" + actions: Fetch, ListVpcs, CreateVpc, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✓ ✓ ✓ ✓ +s1o1-collaborator ✓ ✓ ✓ ✓ +s1o1p1-admin ✓ ✓ ✓ ✓ +s1o1p1-collaborator ✓ ✓ ✓ ✗ +s1o1p1-viewer ✓ ✓ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Vpc "s1o1p1v1" + actions: Fetch, ListSubnets, CreateSubnet, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✓ ✓ ✓ ✓ +s1o1-collaborator ✓ ✓ ✓ ✓ +s1o1p1-admin ✓ ✓ ✓ ✓ +s1o1p1-collaborator ✓ ✓ ✓ ✓ +s1o1p1-viewer ✓ ✓ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Project "s1o1p2" + actions: Fetch, ListVpcs, CreateVpc, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✓ ✓ ✓ ✓ +s1o1-collaborator ✓ ✓ ✓ ✓ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Vpc "s1o1p2v1" + actions: Fetch, ListSubnets, CreateSubnet, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✓ ✓ ✓ ✓ +s1o1-collaborator ✓ ✓ ✓ ✓ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Organization "s1o2" + actions: Fetch, ListProjects, CreateProject, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✗ ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Project "s1o2p1" + actions: Fetch, ListVpcs, CreateVpc, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✗ ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Vpc "s1o2p1v1" + actions: Fetch, ListSubnets, CreateSubnet, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ ✗ +s1-admin ✓ ✓ ✓ ✓ +s1-collaborator ✓ ✓ ✓ ✓ +s1-viewer ✓ ✓ ✗ ✗ +s1o1-admin ✗ ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✗ ✗ ✗ ✗ +s2-collaborator ✗ ✗ ✗ ✗ +s2-viewer ✗ ✗ ✗ ✗ + +resource: Silo "s2" + actions: Fetch, ListOrganizations, CreateOrganization +USER RESULTS FOR EACH ACTION +fleet-admin ✓ ✓ ✓ +fleet-collaborator ✓ ✓ ✓ +fleet-viewer ✓ ✓ ✗ +s1-admin ✗ ✓ ✓ +s1-collaborator ✗ ✓ ✓ +s1-viewer ✗ ✓ ✗ +s1o1-admin ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ +s2-admin ✓ ✓ ✓ +s2-collaborator ✓ ✓ ✓ +s2-viewer ✓ ✓ ✗ + +resource: Organization "s2o3" + actions: Fetch, ListProjects, CreateProject, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✗ ✗ ✗ ✗ +fleet-collaborator ✗ ✗ ✗ ✗ +fleet-viewer ✗ ✗ ✗ ✗ +s1-admin ✗ ✗ ✗ ✗ +s1-collaborator ✗ ✗ ✗ ✗ +s1-viewer ✗ ✗ ✗ ✗ +s1o1-admin ✗ ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✓ ✓ ✓ ✓ +s2-collaborator ✓ ✓ ✓ ✓ +s2-viewer ✓ ✓ ✗ ✗ + +resource: Project "s2o3p1" + actions: Fetch, ListVpcs, CreateVpc, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✗ ✗ ✗ ✗ +fleet-collaborator ✗ ✗ ✗ ✗ +fleet-viewer ✗ ✗ ✗ ✗ +s1-admin ✗ ✗ ✗ ✗ +s1-collaborator ✗ ✗ ✗ ✗ +s1-viewer ✗ ✗ ✗ ✗ +s1o1-admin ✗ ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✓ ✓ ✓ ✓ +s2-collaborator ✓ ✓ ✓ ✓ +s2-viewer ✓ ✓ ✗ ✗ + +resource: Vpc "s2o3p1v1" + actions: Fetch, ListSubnets, CreateSubnet, ModifyDescription +USER RESULTS FOR EACH ACTION +fleet-admin ✗ ✗ ✗ ✗ +fleet-collaborator ✗ ✗ ✗ ✗ +fleet-viewer ✗ ✗ ✗ ✗ +s1-admin ✗ ✗ ✗ ✗ +s1-collaborator ✗ ✗ ✗ ✗ +s1-viewer ✗ ✗ ✗ ✗ +s1o1-admin ✗ ✗ ✗ ✗ +s1o1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-admin ✗ ✗ ✗ ✗ +s1o1p1-collaborator ✗ ✗ ✗ ✗ +s1o1p1-viewer ✗ ✗ ✗ ✗ +s2-admin ✓ ✓ ✓ ✓ +s2-collaborator ✓ ✓ ✓ ✓ +s2-viewer ✓ ✓ ✗ ✗ +