diff --git a/nexus/src/authz/actor.rs b/nexus/src/authz/actor.rs index 1547dca7c71..55812ddb6fc 100644 --- a/nexus/src/authz/actor.rs +++ b/nexus/src/authz/actor.rs @@ -6,6 +6,7 @@ use super::roles::RoleSet; use crate::authn; +use crate::authz::SiloUser; use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; use uuid::Uuid; @@ -90,5 +91,9 @@ impl oso::PolarClass for AuthenticatedActor { ) }) }) + .add_method( + "equals_silo_user", + |a: &AuthenticatedActor, u: SiloUser| a.actor_id == u.id(), + ) } } diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index 59e0952b96f..eb598d4ae41 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -160,15 +160,17 @@ has_relation(fleet: Fleet, "parent_fleet", silo: Silo) # # 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 - # syntax. + # TODO actor.silo is *not* a list, so `in` is incorrect here, but if you + # replace that with `=` it fails! test_silo_read_for_unpriv covers this + # statement if silo in actor.silo; # Any authenticated user should be allowed to list the identity providers of # their silo. has_permission(actor: AuthenticatedActor, "list_identity_providers", silo: Silo) - # TODO-security TODO-coverage We should have a test that exercises this - # syntax. + # TODO actor.silo is *not* a list, so `in` is incorrect here, but if you + # replace that with `=` it fails! test_list_silo_idps_for_unpriv covers + # this statement if silo in actor.silo; resource Organization { @@ -249,6 +251,10 @@ resource SiloUser { has_relation(silo: Silo, "parent_silo", user: SiloUser) if user.silo = silo; +# authenticated actors have all permissions on themselves +has_permission(actor: AuthenticatedActor, _perm: String, silo_user: SiloUser) + if actor.equals_silo_user(silo_user); + resource SshKey { permissions = [ "read", "modify" ]; relations = { silo_user: SiloUser }; @@ -346,6 +352,10 @@ resource GlobalImageList { has_relation(fleet: Fleet, "parent_fleet", global_image_list: GlobalImageList) if global_image_list.fleet = fleet; +# Any authenticated user can list and read global images +has_permission(_actor: AuthenticatedActor, "list_children", _global_image_list: GlobalImageList); +has_permission(_actor: AuthenticatedActor, "read", _global_image: GlobalImage); + # Describes the policy for creating and managing web console sessions. resource ConsoleSessionList { permissions = [ "create_child" ]; diff --git a/nexus/tests/integration_tests/authz.rs b/nexus/tests/integration_tests/authz.rs new file mode 100644 index 00000000000..ae801ffb69f --- /dev/null +++ b/nexus/tests/integration_tests/authz.rs @@ -0,0 +1,439 @@ +// 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/. + +//! Tests for authz policy not covered in the set of unauthorized tests + +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; +use nexus_test_utils::ControlPlaneTestContext; +use nexus_test_utils_macros::nexus_test; + +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_nexus::external_api::params; +use omicron_nexus::external_api::shared; +use omicron_nexus::external_api::views; +use omicron_nexus::TestInterfaces; + +use dropshot::ResultsPage; +use nexus_test_utils::resource_helpers::create_silo; + +use httptest::{matchers::*, responders::*, Expectation, ServerBuilder}; + +use uuid::Uuid; + +// Test that an authenticated, unprivileged user has full CRUD access to their SSH keys +#[nexus_test] +async fn test_ssh_key_crud_for_unpriv(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a silo with an unprivileged user + let silo = + create_silo(&client, "authz", true, shared::UserProvisionType::Fixed) + .await; + + let new_silo_user_id = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, new_silo_user_id, "unpriv".into()) + .await + .unwrap(); + + let name = "akey"; + let description = "authz test"; + let public_key = "AAAAAAAAAAAAAAA"; + + // Create a key + let _new_key: views::SshKey = NexusRequest::objects_post( + client, + "/session/me/sshkeys", + ¶ms::SshKeyCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: description.to_string(), + }, + public_key: public_key.to_string(), + }, + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make POST request") + .parsed_body() + .unwrap(); + + // Fetch that key + let _fetched_key: views::SshKey = NexusRequest::object_get( + client, + &format!("/session/me/sshkeys/{}", name), + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + // List keys + let _keys: ResultsPage = + NexusRequest::object_get(client, &"/session/me/sshkeys") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + // Delete the key + NexusRequest::object_delete( + client, + &format!("/session/me/sshkeys/{}", name), + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to DELETE key"); +} + +// Test that a user cannot read other user's SSH keys +#[nexus_test] +async fn test_cannot_read_others_ssh_keys(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a silo with a two unprivileged users + let silo = + create_silo(&client, "authz", true, shared::UserProvisionType::Fixed) + .await; + + let user1 = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, user1, "user1".into()) + .await + .unwrap(); + + let user2 = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, user2, "user2".into()) + .await + .unwrap(); + + // Create a key for user1 + + let name = "akey"; + let description = "authz test"; + let public_key = "AAAAAAAAAAAAAAA"; + + // Create a key + let _new_key: views::SshKey = NexusRequest::objects_post( + client, + "/session/me/sshkeys", + ¶ms::SshKeyCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: description.to_string(), + }, + public_key: public_key.to_string(), + }, + ) + .authn_as(AuthnMode::SiloUser(user1)) + .execute() + .await + .expect("failed to make POST request") + .parsed_body() + .unwrap(); + + // user1 can read that key + let _fetched_key: views::SshKey = NexusRequest::object_get( + client, + &format!("/session/me/sshkeys/{}", name), + ) + .authn_as(AuthnMode::SiloUser(user1)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + // user2 cannot - they should see 404, not 403 + NexusRequest::new( + RequestBuilder::new( + client, + http::Method::GET, + &format!("/session/me/sshkeys/{}", name), + ) + .expect_status(Some(http::StatusCode::NOT_FOUND)), + ) + .authn_as(AuthnMode::SiloUser(user2)) + .execute() + .await + .expect("GET request should have failed"); + + NexusRequest::new( + RequestBuilder::new( + client, + http::Method::DELETE, + &format!("/session/me/sshkeys/{}", name), + ) + .expect_status(Some(http::StatusCode::NOT_FOUND)), + ) + .authn_as(AuthnMode::SiloUser(user2)) + .execute() + .await + .expect("GET request should have failed"); + + // it also shouldn't show up in their list + let user2_keys: ResultsPage = + NexusRequest::object_get(client, &"/session/me/sshkeys") + .authn_as(AuthnMode::SiloUser(user2)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + assert!(user2_keys.items.is_empty()); +} + +// Test that an authenticated, unprivileged user can list and read global images +#[nexus_test] +async fn test_global_image_read_for_unpriv( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a silo with an unprivileged user + let silo = + create_silo(&client, "authz", true, shared::UserProvisionType::Fixed) + .await; + + let new_silo_user_id = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, new_silo_user_id, "unpriv".into()) + .await + .unwrap(); + + // Create a global image using AuthnMode::PrivilegedUser + let server = ServerBuilder::new().run().unwrap(); + server.expect( + Expectation::matching(request::method_path("HEAD", "/image.raw")) + .times(1..) + .respond_with( + status_code(200).append_header( + "Content-Length", + format!("{}", 4096 * 1000), + ), + ), + ); + + let image_create_params = params::GlobalImageCreate { + identity: IdentityMetadataCreateParams { + name: "alpine-edge".parse().unwrap(), + description: String::from( + "you can boot any image, as long as it's alpine", + ), + }, + source: params::ImageSource::Url { + url: server.url("/image.raw").to_string(), + }, + distribution: params::Distribution { + name: "alpine".parse().unwrap(), + version: "edge".into(), + }, + block_size: params::BlockSize::try_from(512).unwrap(), + }; + + NexusRequest::objects_post(client, "/images", &image_create_params) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + + // The unprivileged user: + + // - can list global images + let _images: ResultsPage = + NexusRequest::object_get(client, &"/images") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + // - can read a global image + let _image: views::GlobalImage = + NexusRequest::object_get(client, &"/images/alpine-edge") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + // - cannot create a global image - should get 403 + NexusRequest::new( + RequestBuilder::new(client, http::Method::POST, &"/images") + .body(Some(&image_create_params)) + .expect_status(Some(http::StatusCode::FORBIDDEN)), + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("POST request should have failed"); + + // - cannot delete a global image - also should get a 404 because the + // unprivileged user cannot see this resource when they're trying to + // delete it + NexusRequest::new( + RequestBuilder::new( + client, + http::Method::DELETE, + &"/images/alpine-edge", + ) + .expect_status(Some(http::StatusCode::NOT_FOUND)), + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("DELETE request should have failed"); +} + +// Test that an authenticated, unprivileged user can list their silo's users +#[nexus_test] +async fn test_list_silo_users_for_unpriv(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a silo with an unprivileged user + let silo = + create_silo(&client, "authz", true, shared::UserProvisionType::Fixed) + .await; + + let new_silo_user_id = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, new_silo_user_id, "unpriv".into()) + .await + .unwrap(); + + // Create another silo with another unprivileged user + let silo = + create_silo(&client, "other", true, shared::UserProvisionType::Fixed) + .await; + + nexus + .silo_user_create(silo.identity.id, Uuid::new_v4(), "otheruser".into()) + .await + .unwrap(); + + // Listing users should work + let users: ResultsPage = + NexusRequest::object_get(client, &"/users") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + // And only show the first silo's user + let user_ids: Vec = users.items.iter().map(|x| x.id).collect(); + assert_eq!(user_ids, vec![new_silo_user_id]); +} + +// Test that an authenticated, unprivileged user can list their silo identity +// providers +#[nexus_test] +async fn test_list_silo_idps_for_unpriv(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a silo with an unprivileged user + let silo = + create_silo(&client, "authz", true, shared::UserProvisionType::Fixed) + .await; + + let new_silo_user_id = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, new_silo_user_id, "unpriv".into()) + .await + .unwrap(); + + let _users: ResultsPage = + NexusRequest::object_get(client, &"/silos/authz/identity_providers") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); +} + +// Test that an authenticated, unprivileged user can access /session/me +#[nexus_test] +async fn test_session_me_for_unpriv(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a silo with an unprivileged user + let silo = + create_silo(&client, "authz", true, shared::UserProvisionType::Fixed) + .await; + + let new_silo_user_id = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, new_silo_user_id, "unpriv".into()) + .await + .unwrap(); + + let _session_user: views::SessionUser = + NexusRequest::object_get(client, &"/session/me") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); +} + +// Test that an authenticated, unprivileged user can access their own silo +#[nexus_test] +async fn test_silo_read_for_unpriv(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create a silo with an unprivileged user + let silo = + create_silo(&client, "authz", true, shared::UserProvisionType::Fixed) + .await; + + let new_silo_user_id = Uuid::new_v4(); + nexus + .silo_user_create(silo.identity.id, new_silo_user_id, "unpriv".into()) + .await + .unwrap(); + + // Create another silo + let _silo = + create_silo(&client, "other", true, shared::UserProvisionType::Fixed) + .await; + + // That user can access their own silo + let _silo: views::Silo = NexusRequest::object_get(client, &"/silos/authz") + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + + // But not others + NexusRequest::new( + RequestBuilder::new(client, http::Method::GET, &"/silos/other") + .expect_status(Some(http::StatusCode::NOT_FOUND)), + ) + .authn_as(AuthnMode::SiloUser(new_silo_user_id)) + .execute() + .await + .expect("GET request should have failed"); +} diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index e73c69851f0..ab4b344b361 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -313,6 +313,21 @@ lazy_static! { }, disk: DEMO_DISK_NAME.clone(), }; + + // SSH keys + pub static ref DEMO_SSHKEYS_URL: &'static str = "/session/me/sshkeys"; + pub static ref DEMO_SSHKEY_NAME: Name = "aaaaa-ssh-key".parse().unwrap(); + pub static ref DEMO_SSHKEY_CREATE: params::SshKeyCreate = params::SshKeyCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_SSHKEY_NAME.clone(), + description: "a demo key".to_string(), + }, + + public_key: "AAAAAAAAAAAAAAA".to_string(), + }; + + pub static ref DEMO_SPECIFIC_SSHKEY_URL: String = + format!("{}/{}", *DEMO_SSHKEYS_URL, *DEMO_SSHKEY_NAME); } lazy_static! { @@ -346,6 +361,7 @@ lazy_static! { /// /// These structs are also used to check whether we're covering all endpoints in /// the public OpenAPI spec. +#[derive(Debug)] pub struct VerifyEndpoint { /// URL path for the HTTP resource to test /// @@ -365,6 +381,9 @@ pub struct VerifyEndpoint { /// unauthorized users will get a 404. pub visibility: Visibility, + /// Specify level of unprivileged access an authenticated user has + pub unprivileged_access: UnprivilegedAccess, + /// Specifies what HTTP methods are supported for this HTTP resource /// /// The test runner tests a variety of HTTP methods. For each method, if @@ -377,7 +396,21 @@ pub struct VerifyEndpoint { pub allowed_methods: Vec, } +/// Describe what access authenticated unprivileged users have. +#[derive(Debug, PartialEq)] +pub enum UnprivilegedAccess { + /// Users have full CRUD access to the endpoint + Full, + + /// Users only have read access to the endpoint + ReadOnly, + + /// Users have no access at all to the endpoint + None, +} + /// Describes the visibility of an HTTP resource +#[derive(Debug)] pub enum Visibility { /// All users can see the resource (including unauthenticated or /// unauthorized users) @@ -392,6 +425,7 @@ pub enum Visibility { } /// Describes an HTTP method supported by a particular API endpoint +#[derive(Debug)] pub enum AllowedMethod { /// HTTP "DELETE" method Delete, @@ -464,6 +498,7 @@ lazy_static! { VerifyEndpoint { url: *POLICY_URL, visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -480,6 +515,7 @@ lazy_static! { VerifyEndpoint { url: *DEMO_IP_POOLS_URL, visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -492,6 +528,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_IP_POOL_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -505,6 +542,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_IP_POOL_RANGES_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get ], @@ -514,6 +552,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_IP_POOL_RANGES_ADD_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post( serde_json::to_value(&*DEMO_IP_POOL_RANGE).unwrap() @@ -525,6 +564,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_IP_POOL_RANGES_DEL_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post( serde_json::to_value(&*DEMO_IP_POOL_RANGE).unwrap() @@ -536,6 +576,7 @@ lazy_static! { VerifyEndpoint { url: "/silos", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -546,6 +587,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_SILO_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Delete, @@ -554,6 +596,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_SILO_POLICY_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -566,12 +609,21 @@ lazy_static! { ], }, + VerifyEndpoint { + url: "/users", + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, /* Organizations */ VerifyEndpoint { url: "/organizations", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -583,6 +635,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/organizations/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -591,6 +644,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_ORG_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Delete, @@ -608,6 +662,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_ORG_POLICY_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -633,6 +688,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_ORG_PROJECTS_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -644,6 +700,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/projects/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -652,6 +709,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_PROJECT_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Delete, @@ -669,6 +727,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_PROJECT_POLICY_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -685,6 +744,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_PROJECT_URL_VPCS, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -696,6 +756,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/vpcs/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -704,6 +765,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -723,6 +785,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_URL_FIREWALL_RULES, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -737,6 +800,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_URL_SUBNETS, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -748,6 +812,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/vpc-subnets/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -756,6 +821,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_SUBNET_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -773,6 +839,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_SUBNET_INTERFACES_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -783,6 +850,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_URL_ROUTERS, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -794,6 +862,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/vpc-routers/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -802,6 +871,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_ROUTER_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -821,6 +891,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_VPC_ROUTER_URL_ROUTES, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -832,6 +903,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/vpc-router-routes/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -840,6 +912,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_ROUTER_ROUTE_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Put( @@ -858,12 +931,12 @@ lazy_static! { ], }, - /* Disks */ VerifyEndpoint { url: &*DEMO_PROJECT_URL_DISKS, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -875,6 +948,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/disks/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -883,6 +957,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_DISK_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Delete, @@ -892,6 +967,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_DISKS_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -899,6 +975,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_DISKS_ATTACH_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post( serde_json::to_value(params::DiskIdentifier { @@ -910,6 +987,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_DISKS_DETACH_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post( serde_json::to_value(params::DiskIdentifier { @@ -924,6 +1002,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_PROJECT_URL_IMAGES, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::GetUnimplemented, AllowedMethod::Post( @@ -935,6 +1014,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/images/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::GetUnimplemented, ], @@ -943,6 +1023,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_PROJECT_IMAGE_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::GetUnimplemented, AllowedMethod::Delete, @@ -954,6 +1035,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_PROJECT_URL_SNAPSHOTS, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::GetUnimplemented, AllowedMethod::Post( @@ -965,6 +1047,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/snapshots/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::GetUnimplemented, ], @@ -973,6 +1056,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_SNAPSHOT_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::GetUnimplemented, AllowedMethod::Delete, @@ -983,6 +1067,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_PROJECT_URL_INSTANCES, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -994,6 +1079,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/instances/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -1002,6 +1088,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Delete, @@ -1011,6 +1098,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_START_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post(serde_json::Value::Null) ], @@ -1018,6 +1106,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_STOP_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post(serde_json::Value::Null) ], @@ -1025,6 +1114,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_REBOOT_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post(serde_json::Value::Null) ], @@ -1032,6 +1122,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_MIGRATE_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Post(serde_json::to_value( params::InstanceMigrate { @@ -1043,6 +1134,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_SERIAL_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::GetNonexistent // has required query parameters ], @@ -1052,6 +1144,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_NICS_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -1063,6 +1156,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/network-interfaces/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, ], @@ -1071,6 +1165,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_INSTANCE_NIC_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Delete, @@ -1085,22 +1180,26 @@ lazy_static! { VerifyEndpoint { url: "/roles", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { url: "/roles/fleet.admin", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { url: "/system/user", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { url: &*URL_USERS_DB_INIT, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, @@ -1109,24 +1208,28 @@ lazy_static! { VerifyEndpoint { url: "/hardware/racks", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { url: &*HARDWARE_RACK_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { url: "/hardware/sleds", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { url: &*HARDWARE_SLED_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, @@ -1135,12 +1238,14 @@ lazy_static! { VerifyEndpoint { url: "/sagas", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, VerifyEndpoint { url: "/sagas/48a1b8c8-fc1c-6fea-9de9-fdeb8dda7823", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::GetNonexistent], }, @@ -1149,6 +1254,7 @@ lazy_static! { VerifyEndpoint { url: "/timeseries/schema", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, @@ -1157,6 +1263,7 @@ lazy_static! { VerifyEndpoint { url: "/updates/refresh", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Post( serde_json::Value::Null )], @@ -1167,6 +1274,7 @@ lazy_static! { VerifyEndpoint { url: "/images", visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Post( @@ -1178,6 +1286,7 @@ lazy_static! { VerifyEndpoint { url: "/by-id/global-images/{id}", visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![ AllowedMethod::Get, ], @@ -1186,6 +1295,7 @@ lazy_static! { VerifyEndpoint { url: &*DEMO_GLOBAL_IMAGE_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![ AllowedMethod::Get, AllowedMethod::Delete, @@ -1194,18 +1304,19 @@ lazy_static! { /* Silo identity providers */ - /* VerifyEndpoint { - url: &*IDENTITY_PROVIDERS_URL, // in ignore list + url: &*IDENTITY_PROVIDERS_URL, visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![ AllowedMethod::Get, ], }, - */ + VerifyEndpoint { url: &*SAML_IDENTITY_PROVIDERS_URL, visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Post( serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), )], @@ -1213,7 +1324,42 @@ lazy_static! { VerifyEndpoint { url: &*SPECIFIC_SAML_IDENTITY_PROVIDER_URL, visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, + + /* Misc */ + + VerifyEndpoint { + url: "/session/me", + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, + + /* SSH keys */ + + VerifyEndpoint { + url: &*DEMO_SSHKEYS_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::Full, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Post( + serde_json::to_value(&*DEMO_SSHKEY_CREATE).unwrap(), + ), + ], + }, + VerifyEndpoint { + url: &*DEMO_SPECIFIC_SSHKEY_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::Full, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + ], + }, ]; } diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 1bd81ef303e..f40218a80b9 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; mod basic; mod commands; mod console_api; diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 32504da9cf3..46dde6ae906 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -266,6 +266,12 @@ lazy_static! { body: serde_json::to_value(&*SAML_IDENTITY_PROVIDER).unwrap(), id_routes: vec![], }, + // Create a SSH key + SetupReq::Post { + url: &*DEMO_SSHKEYS_URL, + body: serde_json::to_value(&*DEMO_SSHKEY_CREATE).unwrap(), + id_routes: vec![], + }, ]; } @@ -339,7 +345,8 @@ async fn verify_endpoint( Visibility::Protected => StatusCode::NOT_FOUND, }; - // For routes with an id param, replace the id param with the setup response if present. + // For routes with an id param, replace the id param with the setup response + // if present. let uri = if endpoint.url.contains("{id}") { match setup_response { Some(response) => endpoint.url.replace( @@ -422,21 +429,31 @@ async fn verify_endpoint( // First, make an authenticated, unauthorized request. info!(log, "test: authenticated, unauthorized"; "method" => ?method); - let expected_status = match allowed { - Some(_) => unauthz_status, - None => StatusCode::METHOD_NOT_ALLOWED, - }; - let response = NexusRequest::new( - RequestBuilder::new(client, method.clone(), uri.as_str()) - .body(body.as_ref()) - .expect_status(Some(expected_status)), - ) - .authn_as(AuthnMode::UnprivilegedUser) - .execute() - .await - .unwrap(); - verify_response(&response); - record_operation(WhichTest::Unprivileged(&expected_status)); + + // Some authz policy states that authenticated users get implicit + // privileges for some resources. Do not test for those here. They + // should be covered in other resource specific tests. We're only + // checking the behavior of cases that get denied in this test. + if endpoint.unprivileged_access != UnprivilegedAccess::None { + // "This door is opened elsewhere." + print!("-"); + } else { + let expected_status = match allowed { + Some(_) => unauthz_status, + None => StatusCode::METHOD_NOT_ALLOWED, + }; + let response = NexusRequest::new( + RequestBuilder::new(client, method.clone(), &uri) + .body(body.as_ref()) + .expect_status(Some(expected_status)), + ) + .authn_as(AuthnMode::UnprivilegedUser) + .execute() + .await + .unwrap(); + verify_response(&response); + record_operation(WhichTest::Unprivileged(&expected_status)); + } // Next, make an unauthenticated request. info!(log, "test: unauthenticated"; "method" => ?method); diff --git a/nexus/tests/integration_tests/unauthorized_coverage.rs b/nexus/tests/integration_tests/unauthorized_coverage.rs index e92a5af98be..d5490e0ab47 100644 --- a/nexus/tests/integration_tests/unauthorized_coverage.rs +++ b/nexus/tests/integration_tests/unauthorized_coverage.rs @@ -81,6 +81,8 @@ fn test_unauthorized_coverage() { ); for v in &*VERIFY_ENDPOINTS { for m in &v.allowed_methods { + // Remove the method and path from the list of operations if there's + // a VerifyEndpoint for it. let method_string = m.http_method().to_string().to_uppercase(); let found = spec_operations.iter().find(|(op, regex)| { op.method.to_uppercase() == method_string diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 6a0cae41685..6107c0f27fa 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,15 +1,8 @@ API endpoints with no coverage in authz tests: -session_sshkey_delete (delete "/session/me/sshkeys/{ssh_key_name}") login (get "/login/{silo_name}/{provider_name}") -session_me (get "/session/me") -session_sshkey_list (get "/session/me/sshkeys") -session_sshkey_view (get "/session/me/sshkeys/{ssh_key_name}") -silo_identity_provider_list (get "/silos/{silo_name}/identity_providers") -user_list (get "/users") device_auth_request (post "/device/auth") device_auth_confirm (post "/device/confirm") device_access_token (post "/device/token") spoof_login (post "/login") consume_credentials (post "/login/{silo_name}/{provider_name}") logout (post "/logout") -session_sshkey_create (post "/session/me/sshkeys")