From f44672dbb8592e31af61e104c0fdde25611674a0 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Thu, 24 Mar 2022 22:22:42 -0400 Subject: [PATCH 1/2] Introduce concept of Silos Add silos, which will isolate organizations, and provide a namespace for users and groups. This required adding Silo id to Actor, so users that have authenticated now have an associated Silo id that can be used to restrict organization lookup. Silos can be created, read, and deleted. Modification is a TODO. Silos can be marked discoverable or not, to support a tenancy model where users and resources are strongly isolated from each other. A few tests have been modified to use authn_as because an earlier version of this branch added OpContext to every endpoint, but that was reverted because the blast radius of the PR would have been too large. What remains are a few modified tests that make authenticated calls. When all endpoints are protected and each datastore function has an OpContext, Silo can be looked up on Actor. For now, there are places hard coding as the built-in Silo. Still TODO: - authz for silos and silo users - some testing is dependent on ^ - PUT /silos/{name} - building on top of silos --- common/src/api/external/error.rs | 5 + common/src/api/external/mod.rs | 3 + common/src/sql/dbinit.sql | 48 ++- nexus/src/authn/external/mod.rs | 16 +- nexus/src/authn/external/session_cookie.rs | 26 +- nexus/src/authn/external/spoof.rs | 31 +- nexus/src/authn/mod.rs | 69 ++- nexus/src/authz/actor.rs | 2 +- nexus/src/authz/roles.rs | 4 +- nexus/src/context.rs | 21 +- nexus/src/db/datastore.rs | 404 ++++++++++++++++-- nexus/src/db/fixed_data/mod.rs | 3 +- nexus/src/db/fixed_data/silo_builtin.rs | 11 + nexus/src/db/fixed_data/user_builtin.rs | 12 + nexus/src/db/model.rs | 82 +++- nexus/src/db/schema.rs | 27 +- nexus/src/external_api/console_api.rs | 8 +- nexus/src/external_api/http_entrypoints.rs | 122 +++++- nexus/src/external_api/params.rs | 11 + nexus/src/external_api/tag-config.json | 6 + nexus/src/external_api/views.rs | 22 +- nexus/src/nexus.rs | 111 ++++- nexus/src/populate.rs | 4 + nexus/test-utils/src/http_testing.rs | 48 ++- nexus/test-utils/src/resource_helpers.rs | 21 +- nexus/tests/integration_tests/authn_http.rs | 23 +- nexus/tests/integration_tests/basic.rs | 212 ++++----- nexus/tests/integration_tests/disks.rs | 29 +- nexus/tests/integration_tests/mod.rs | 1 + .../tests/integration_tests/organizations.rs | 17 +- nexus/tests/integration_tests/silos.rs | 142 ++++++ nexus/tests/output/nexus_tags.txt | 7 + .../output/uncovered-authz-endpoints.txt | 4 + openapi/nexus.json | 256 +++++++++++ 34 files changed, 1562 insertions(+), 246 deletions(-) create mode 100644 nexus/src/db/fixed_data/silo_builtin.rs create mode 100644 nexus/tests/integration_tests/silos.rs diff --git a/common/src/api/external/error.rs b/common/src/api/external/error.rs index f66de9cd301..ff7f51313c1 100644 --- a/common/src/api/external/error.rs +++ b/common/src/api/external/error.rs @@ -66,6 +66,8 @@ pub enum LookupType { ByName(String), /// a specific id was requested ById(Uuid), + /// a session token was requested + BySessionToken(String), } impl LookupType { @@ -158,6 +160,9 @@ impl From for HttpError { let (lookup_field, lookup_value) = match lt { LookupType::ByName(name) => ("name", name), LookupType::ById(id) => ("id", id.to_string()), + LookupType::BySessionToken(token) => { + ("session token", token) + } }; let message = format!( "not found: {} with {} \"{}\"", diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index f79e7a4b69c..a7cb8cb3344 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -514,6 +514,9 @@ impl TryFrom for Generation { #[display(style = "kebab-case")] pub enum ResourceType { Fleet, + Silo, + SiloUser, + ConsoleSession, Organization, Project, Dataset, diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index bb4028ec2fa..53cb28f94f2 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -172,6 +172,46 @@ CREATE TABLE omicron.public.volume ( data TEXT NOT NULL ); +/* + * Silos + */ + +CREATE TABLE omicron.public.silo ( + /* Identity metadata */ + id UUID PRIMARY KEY, + + name STRING(128) NOT NULL, + description STRING(512) NOT NULL, + + discoverable BOOL NOT NULL, + + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + + /* child resource generation number, per RFD 192 */ + rcgen INT NOT NULL +); + +CREATE UNIQUE INDEX ON omicron.public.silo ( + name +) WHERE + time_deleted IS NULL; + +/* + * Silo users + */ +CREATE TABLE omicron.public.silo_user ( + /* silo user id */ + id UUID PRIMARY KEY, + + silo_id UUID NOT NULL, + + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ +); + /* * Organizations */ @@ -179,6 +219,10 @@ CREATE TABLE omicron.public.volume ( CREATE TABLE omicron.public.organization ( /* Identity metadata */ id UUID PRIMARY KEY, + + /* FK into Silo table */ + silo_id UUID NOT NULL, + name STRING(63) NOT NULL, description STRING(512) NOT NULL, time_created TIMESTAMPTZ NOT NULL, @@ -747,9 +791,7 @@ CREATE TABLE omicron.public.console_session ( token STRING(40) PRIMARY KEY, time_created TIMESTAMPTZ NOT NULL, time_last_used TIMESTAMPTZ NOT NULL, - -- we're agnostic about what this means until work starts on users, but the - -- naive interpretation is that it points to a row in the User table - user_id UUID NOT NULL + silo_user_id UUID NOT NULL ); -- to be used for cleaning up old tokens diff --git a/nexus/src/authn/external/mod.rs b/nexus/src/authn/external/mod.rs index 2521059166a..e50c15c0c59 100644 --- a/nexus/src/authn/external/mod.rs +++ b/nexus/src/authn/external/mod.rs @@ -22,7 +22,7 @@ impl Authenticator where T: Send + Sync + 'static, { - /// Build a new authentiator that allows only the specified schemes + /// Build a new authenticator that allows only the specified schemes pub fn new( allowed_schemes: Vec>>, ) -> Authenticator { @@ -187,9 +187,10 @@ mod test { let count1 = Arc::new(AtomicU8::new(0)); let mut expected_count1 = 0; let name1 = authn::SchemeName("grunt1"); - let actor1 = authn::Actor( - "1c91bab2-4841-669f-cc32-de80da5bbf39".parse().unwrap(), - ); + let actor1 = authn::Actor { + id: "1c91bab2-4841-669f-cc32-de80da5bbf39".parse().unwrap(), + silo_id: *crate::db::fixed_data::silo_builtin::SILO_ID, + }; let grunt1 = Box::new(GruntScheme { name: name1, next: Arc::clone(&flag1), @@ -201,9 +202,10 @@ mod test { let count2 = Arc::new(AtomicU8::new(0)); let mut expected_count2 = 0; let name2 = authn::SchemeName("grunt2"); - let actor2 = authn::Actor( - "799684af-533a-cb66-b5ac-ab55a791d5ef".parse().unwrap(), - ); + let actor2 = authn::Actor { + id: "799684af-533a-cb66-b5ac-ab55a791d5ef".parse().unwrap(), + silo_id: *crate::db::fixed_data::silo_builtin::SILO_ID, + }; let grunt2 = Box::new(GruntScheme { name: name2, next: Arc::clone(&flag2), diff --git a/nexus/src/authn/external/session_cookie.rs b/nexus/src/authn/external/session_cookie.rs index 376ebe9a290..9fbc8bcdc10 100644 --- a/nexus/src/authn/external/session_cookie.rs +++ b/nexus/src/authn/external/session_cookie.rs @@ -17,7 +17,8 @@ use uuid::Uuid; // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html pub trait Session { - fn user_id(&self) -> Uuid; + fn silo_user_id(&self) -> Uuid; + fn silo_id(&self) -> Uuid; fn time_last_used(&self) -> DateTime; fn time_created(&self) -> DateTime; } @@ -106,7 +107,8 @@ where } }; - let actor = Actor(session.user_id()); + let actor = + Actor { id: session.silo_user_id(), silo_id: session.silo_id() }; // if the session has gone unused for longer than idle_timeout, it is expired let now = Utc::now(); @@ -190,13 +192,18 @@ mod test { #[derive(Clone, Copy)] struct FakeSession { + silo_user_id: Uuid, + silo_id: Uuid, time_created: DateTime, time_last_used: DateTime, } impl Session for FakeSession { - fn user_id(&self) -> Uuid { - Uuid::new_v4() + fn silo_user_id(&self) -> Uuid { + self.silo_user_id + } + fn silo_id(&self) -> Uuid { + self.silo_id } fn time_created(&self) -> DateTime { self.time_created @@ -279,6 +286,8 @@ mod test { sessions: Mutex::new(HashMap::from([( "abc".to_string(), FakeSession { + silo_user_id: Uuid::new_v4(), + silo_id: Uuid::new_v4(), time_last_used: Utc::now() - Duration::hours(2), time_created: Utc::now() - Duration::hours(2), }, @@ -303,6 +312,8 @@ mod test { sessions: Mutex::new(HashMap::from([( "abc".to_string(), FakeSession { + silo_user_id: Uuid::new_v4(), + silo_id: Uuid::new_v4(), time_last_used: Utc::now(), time_created: Utc::now() - Duration::hours(20), }, @@ -328,7 +339,12 @@ mod test { let context = TestServerContext { sessions: Mutex::new(HashMap::from([( "abc".to_string(), - FakeSession { time_last_used, time_created: Utc::now() }, + FakeSession { + silo_user_id: Uuid::new_v4(), + silo_id: Uuid::new_v4(), + time_last_used, + time_created: Utc::now(), + }, )])), }; let result = authn_with_cookie(&context, Some("session=abc")).await; diff --git a/nexus/src/authn/external/spoof.rs b/nexus/src/authn/external/spoof.rs index 06232bc79f3..da1500ba16a 100644 --- a/nexus/src/authn/external/spoof.rs +++ b/nexus/src/authn/external/spoof.rs @@ -55,8 +55,10 @@ const SPOOF_PREFIX: &str = "oxide-spoof-"; lazy_static! { /// Actor (id) used for the special "bad credentials" error - static ref SPOOF_RESERVED_BAD_CREDS_ACTOR: Actor = - Actor("22222222-2222-2222-2222-222222222222".parse().unwrap()); + static ref SPOOF_RESERVED_BAD_CREDS_ACTOR: Actor = Actor { + id: "22222222-2222-2222-2222-222222222222".parse().unwrap(), + silo_id: *crate::db::fixed_data::silo_builtin::SILO_ID, + }; /// Complete HTTP header value to trigger the "bad actor" error pub static ref SPOOF_HEADER_BAD_ACTOR: Authorization = make_header_value_str(SPOOF_RESERVED_BAD_ACTOR).unwrap(); @@ -119,7 +121,13 @@ fn authn_spoof(raw_value: Option<&Authorization>) -> SchemeResult { } match Uuid::parse_str(str_value).context("parsing header value as UUID") { - Ok(id) => SchemeResult::Authenticated(Details { actor: Actor(id) }), + Ok(id) => { + let actor = Actor { + id, + silo_id: *crate::db::fixed_data::silo_builtin::SILO_ID, + }; + SchemeResult::Authenticated(Details { actor }) + } Err(source) => SchemeResult::Failed(Reason::BadFormat { source }), } } @@ -160,15 +168,12 @@ pub fn make_header_value_raw( #[cfg(test)] mod test { - use super::super::super::Details; use super::super::super::Reason; use super::super::SchemeResult; use super::authn_spoof; use super::make_header_value; use super::make_header_value_raw; use super::make_header_value_str; - use crate::authn; - use authn::Actor; use headers::authorization::Bearer; use headers::authorization::Credentials; use headers::Authorization; @@ -228,12 +233,14 @@ mod test { // Success case: the client provided a valid uuid in the header. let success_case = authn_spoof(Some(&test_header)); - assert!(matches!( - success_case, - SchemeResult::Authenticated( - Details { actor: Actor(i) } - ) if i == test_uuid - )); + match success_case { + SchemeResult::Authenticated(details) => { + assert_eq!(details.actor.id, test_uuid); + } + _ => { + assert!(false); + } + }; } #[test] diff --git a/nexus/src/authn/mod.rs b/nexus/src/authn/mod.rs index be7bc1e16ae..b85a4b02638 100644 --- a/nexus/src/authn/mod.rs +++ b/nexus/src/authn/mod.rs @@ -33,6 +33,7 @@ pub use crate::db::fixed_data::user_builtin::USER_INTERNAL_READ; pub use crate::db::fixed_data::user_builtin::USER_SAGA_RECOVERY; pub use crate::db::fixed_data::user_builtin::USER_TEST_PRIVILEGED; pub use crate::db::fixed_data::user_builtin::USER_TEST_UNPRIVILEGED; +use crate::db::model::ConsoleSession; use serde::Deserialize; use serde::Serialize; @@ -64,17 +65,25 @@ impl Context { } /// Returns the authenticated actor if present, Unauthenticated error otherwise - pub fn actor_required(&self) -> Result<&Actor, dropshot::HttpError> { + pub fn actor_required( + &self, + ) -> Result<&Actor, omicron_common::api::external::Error> { match &self.kind { Kind::Authenticated(Details { actor }) => Ok(actor), - Kind::Unauthenticated => Err(dropshot::HttpError::from( - omicron_common::api::external::Error::Unauthenticated { + Kind::Unauthenticated => { + Err(omicron_common::api::external::Error::Unauthenticated { internal_message: "Actor required".to_string(), - }, - )), + }) + } } } + pub fn silo_required( + &self, + ) -> Result { + self.actor_required().map(|actor| actor.silo_id) + } + /// Returns the list of schemes tried, in order /// /// This should generally *not* be exposed to clients. @@ -89,28 +98,39 @@ impl Context { /// Returns an authenticated context for handling internal API contexts pub fn internal_api() -> Context { - Context::context_for_actor(USER_INTERNAL_API.id) + Context::context_for_actor( + USER_INTERNAL_API.id, + USER_INTERNAL_API.silo_id, + ) } /// Returns an authenticated context for saga recovery pub fn internal_saga_recovery() -> Context { - Context::context_for_actor(USER_SAGA_RECOVERY.id) + Context::context_for_actor( + USER_SAGA_RECOVERY.id, + USER_SAGA_RECOVERY.silo_id, + ) } /// Returns an authenticated context for use by internal resource allocation pub fn internal_read() -> Context { - Context::context_for_actor(USER_INTERNAL_READ.id) + Context::context_for_actor( + USER_INTERNAL_READ.id, + USER_INTERNAL_READ.silo_id, + ) } /// Returns an authenticated context for Nexus-startup database /// initialization pub fn internal_db_init() -> Context { - Context::context_for_actor(USER_DB_INIT.id) + Context::context_for_actor(USER_DB_INIT.id, USER_DB_INIT.silo_id) } - fn context_for_actor(actor_id: Uuid) -> Context { + fn context_for_actor(actor_id: Uuid, silo_id: Uuid) -> Context { Context { - kind: Kind::Authenticated(Details { actor: Actor(actor_id) }), + kind: Kind::Authenticated(Details { + actor: Actor { id: actor_id, silo_id: silo_id }, + }), schemes_tried: Vec::new(), } } @@ -124,7 +144,10 @@ impl Context { /// /// This is used for testing. pub fn test_context_for_actor(actor_id: Uuid) -> Context { - Context::context_for_actor(actor_id) + Context::context_for_actor( + actor_id, + *crate::db::fixed_data::silo_builtin::SILO_ID, + ) } } @@ -148,23 +171,23 @@ mod test { // The privileges are (or will be) verified in authz tests. let authn = Context::internal_test_user(); let actor = authn.actor().unwrap(); - assert_eq!(actor.0, USER_TEST_PRIVILEGED.id); + assert_eq!(actor.id, USER_TEST_PRIVILEGED.id); let authn = Context::internal_read(); let actor = authn.actor().unwrap(); - assert_eq!(actor.0, USER_INTERNAL_READ.id); + assert_eq!(actor.id, USER_INTERNAL_READ.id); let authn = Context::internal_db_init(); let actor = authn.actor().unwrap(); - assert_eq!(actor.0, USER_DB_INIT.id); + assert_eq!(actor.id, USER_DB_INIT.id); let authn = Context::internal_saga_recovery(); let actor = authn.actor().unwrap(); - assert_eq!(actor.0, USER_SAGA_RECOVERY.id); + assert_eq!(actor.id, USER_SAGA_RECOVERY.id); let authn = Context::internal_api(); let actor = authn.actor().unwrap(); - assert_eq!(actor.0, USER_INTERNAL_API.id); + assert_eq!(actor.id, USER_INTERNAL_API.id); } } @@ -190,7 +213,17 @@ pub struct Details { /// Who is performing an operation #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct Actor(pub Uuid); +pub struct Actor { + pub id: Uuid, // silo user id + pub silo_id: Uuid, +} + +/// A console session with the silo id of the authenticated user +#[derive(Clone, Debug)] +pub struct ConsoleSessionWithSiloId { + pub console_session: ConsoleSession, + pub silo_id: Uuid, +} /// Label for a particular authentication scheme (used in log messages and /// internal error messages) diff --git a/nexus/src/authz/actor.rs b/nexus/src/authz/actor.rs index 6d4c9df0afb..572262c8354 100644 --- a/nexus/src/authz/actor.rs +++ b/nexus/src/authz/actor.rs @@ -23,7 +23,7 @@ impl AnyActor { let actor = authn.actor(); AnyActor { authenticated: actor.is_some(), - actor_id: actor.map(|a| a.0), + actor_id: actor.map(|a| a.id), roles, } } diff --git a/nexus/src/authz/roles.rs b/nexus/src/authz/roles.rs index 8ada1131e34..1dc9f3210a0 100644 --- a/nexus/src/authz/roles.rs +++ b/nexus/src/authz/roles.rs @@ -136,7 +136,7 @@ pub async fn load_roles_for_resource( // ... then fetch all the roles for this user that are associated with // this resource. trace!(opctx.log, "loading roles"; - "actor_id" => actor_id.0.to_string(), + "actor_id" => actor_id.id.to_string(), "resource_type" => ?resource_type, "resource_id" => resource_id.to_string(), ); @@ -144,7 +144,7 @@ pub async fn load_roles_for_resource( let roles = datastore .role_asgn_builtin_list_for( opctx, - actor_id.0, + actor_id.id, resource_type, resource_id, ) diff --git a/nexus/src/context.rs b/nexus/src/context.rs index f09e4156489..a5d21e7aba4 100644 --- a/nexus/src/context.rs +++ b/nexus/src/context.rs @@ -9,9 +9,8 @@ use super::config; use super::db; use super::Nexus; use crate::authn::external::session_cookie::{Session, SessionStore}; -use crate::authn::Actor; +use crate::authn::ConsoleSessionWithSiloId; use crate::authz::AuthorizedResource; -use crate::db::model::ConsoleSession; use crate::db::DataStore; use crate::saga_interface::SagaContext; use async_trait::async_trait; @@ -274,7 +273,8 @@ impl OpContext { ) -> (slog::Logger, BTreeMap) { let mut metadata = BTreeMap::new(); - let log = if let Some(Actor(actor_id)) = authn.actor() { + let log = if let Some(actor) = authn.actor() { + let actor_id = actor.id; metadata .insert(String::from("authenticated"), String::from("true")); metadata.insert(String::from("actor"), actor_id.to_string()); @@ -493,7 +493,7 @@ mod test { #[async_trait] impl SessionStore for Arc { - type SessionModel = ConsoleSession; + type SessionModel = ConsoleSessionWithSiloId; async fn session_fetch(&self, token: String) -> Option { self.nexus.session_fetch(token).await.ok() @@ -519,14 +519,17 @@ impl SessionStore for Arc { } } -impl Session for ConsoleSession { - fn user_id(&self) -> Uuid { - self.user_id +impl Session for ConsoleSessionWithSiloId { + fn silo_user_id(&self) -> Uuid { + self.console_session.silo_user_id + } + fn silo_id(&self) -> Uuid { + self.silo_id } fn time_last_used(&self) -> DateTime { - self.time_last_used + self.console_session.time_last_used } fn time_created(&self) -> DateTime { - self.time_created + self.console_session.time_created } } diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 8aabb87ea50..e26d30efcca 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -30,6 +30,7 @@ use crate::authz; use crate::context::OpContext; use crate::db::fixed_data::role_assignment_builtin::BUILTIN_ROLE_ASSIGNMENTS; use crate::db::fixed_data::role_builtin::BUILTIN_ROLES; +use crate::db::fixed_data::silo_builtin::SILO_ID; use crate::external_api::params; use async_bb8_diesel::{AsyncConnection, AsyncRunQueryDsl, ConnectionManager}; use chrono::Utc; @@ -66,9 +67,9 @@ use crate::db::{ Name, NetworkInterface, Organization, OrganizationUpdate, OximeterInfo, ProducerEndpoint, Project, ProjectUpdate, Region, RoleAssignmentBuiltin, RoleBuiltin, RouterRoute, RouterRouteUpdate, - Sled, UpdateArtifactKind, UpdateAvailableArtifact, UserBuiltin, Volume, - Vpc, VpcFirewallRule, VpcRouter, VpcRouterUpdate, VpcSubnet, - VpcSubnetUpdate, VpcUpdate, Zpool, + Silo, SiloUser, Sled, UpdateArtifactKind, UpdateAvailableArtifact, + UserBuiltin, Volume, Vpc, VpcFirewallRule, VpcRouter, VpcRouterUpdate, + VpcSubnet, VpcSubnetUpdate, VpcUpdate, Zpool, }, pagination::paginated, pagination::paginated_multicolumn, @@ -540,20 +541,25 @@ impl DataStore { opctx.authorize(authz::Action::CreateChild, &authz::FLEET).await?; let name = organization.name().as_str().to_string(); - diesel::insert_into(dsl::organization) - .values(organization) - .returning(Organization::as_returning()) - .get_result_async(self.pool_authorized(opctx).await?) - .await - .map_err(|e| { + let silo_id = organization.silo_id(); + + Silo::insert_resource( + silo_id, + diesel::insert_into(dsl::organization).values(organization), + ) + .insert_and_get_result_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| match e { + AsyncInsertError::CollectionNotFound => Error::InternalError { + internal_message: format!("attempting to create an organization under non-existent silo {}", silo_id), + }, + AsyncInsertError::DatabaseError(e) => { public_error_from_diesel_pool( e, - ErrorHandler::Conflict( - ResourceType::Organization, - name.as_str(), - ), + ErrorHandler::Conflict(ResourceType::Organization, &name), ) - }) + } + }) } /// Fetches an Organization from the database and returns both the database @@ -590,9 +596,15 @@ impl DataStore { name: &Name, ) -> LookupResult<(authz::Organization, Organization)> { use db::schema::organization::dsl; + + // TODO-security uncomment when all endpoints are protected by authn + //let silo_id = opctx.authn.silo_required()?; + let silo_id = *SILO_ID; + dsl::organization .filter(dsl::time_deleted.is_null()) .filter(dsl::name.eq(name.clone())) + .filter(dsl::silo_id.eq(silo_id)) .select(Organization::as_select()) .get_result_async::(self.pool()) .await @@ -617,6 +629,7 @@ impl DataStore { /// Fetch an [`authz::Organization`] based on its id pub async fn organization_lookup_by_id( &self, + // TODO: OpContext, to verify actor has permission to lookup organization_id: Uuid, ) -> LookupResult { use db::schema::organization::dsl; @@ -3028,19 +3041,31 @@ impl DataStore { pub async fn session_fetch( &self, token: String, - ) -> LookupResult { + ) -> LookupResult { use db::schema::console_session::dsl; - dsl::console_session + + let console_session = dsl::console_session .filter(dsl::token.eq(token.clone())) .select(ConsoleSession::as_select()) .first_async(self.pool()) .await .map_err(|e| { - Error::internal_error(&format!( - "error fetching session: {:?}", - e - )) - }) + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::ConsoleSession, + LookupType::BySessionToken(token), + ), + ) + })?; + + let silo_user = + self.silo_user_fetch(console_session.silo_user_id).await?; + + Ok(authn::ConsoleSessionWithSiloId { + console_session, + silo_id: silo_user.silo_id, + }) } pub async fn session_create( @@ -3065,10 +3090,10 @@ impl DataStore { pub async fn session_update_last_used( &self, token: String, - ) -> UpdateResult { + ) -> UpdateResult { use db::schema::console_session::dsl; - diesel::update(dsl::console_session) + let console_session = diesel::update(dsl::console_session) .filter(dsl::token.eq(token.clone())) .set((dsl::time_last_used.eq(Utc::now()),)) .returning(ConsoleSession::as_returning()) @@ -3079,7 +3104,22 @@ impl DataStore { "error renewing session: {:?}", e )) - }) + })?; + + let silo_user = self + .silo_user_fetch(console_session.silo_user_id) + .await + .map_err(|e| { + Error::internal_error(&format!( + "error fetching silo id: {:?}", + e + )) + })?; + + Ok(authn::ConsoleSessionWithSiloId { + console_session, + silo_id: silo_user.silo_id, + }) } // putting "hard" in the name because we don't do this with any other model @@ -3176,6 +3216,18 @@ impl DataStore { }) .collect::>(); + debug!(opctx.log, "creating silo_user entries for built-in users"); + + for builtin_user in &builtin_users { + self.silo_user_create(SiloUser::new( + *SILO_ID, + builtin_user.identity.id, /* silo user id */ + )) + .await?; + } + + info!(opctx.log, "created silo_user entries for built-in users"); + debug!(opctx.log, "attempting to create built-in users"); let count = diesel::insert_into(dsl::user_builtin) .values(builtin_users) @@ -3187,6 +3239,7 @@ impl DataStore { public_error_from_diesel_pool(e, ErrorHandler::Server) })?; info!(opctx.log, "created {} built-in users", count); + Ok(()) } @@ -3409,6 +3462,258 @@ impl DataStore { )) }) } + + pub async fn silo_user_fetch( + &self, + silo_user_id: Uuid, + ) -> LookupResult { + use db::schema::silo_user::dsl; + + dsl::silo_user + .filter(dsl::id.eq(silo_user_id)) + .filter(dsl::time_deleted.is_null()) + .select(SiloUser::as_select()) + .first_async(self.pool()) + .await + .map_err(|e| { + Error::internal_error(&format!( + "error fetching silo user: {:?}", + e + )) + }) + } + + pub async fn silo_user_create( + &self, + silo_user: SiloUser, + ) -> CreateResult { + use db::schema::silo_user::dsl; + + diesel::insert_into(dsl::silo_user) + .values(silo_user) + .returning(SiloUser::as_returning()) + .get_result_async(self.pool()) + .await + .map_err(|e| { + Error::internal_error(&format!( + "error creating silo user: {:?}", + e + )) + }) + } + + /// Load built-in silos into the database + pub async fn load_builtin_silos( + &self, + opctx: &OpContext, + ) -> Result<(), Error> { + debug!(opctx.log, "attempting to create built-in silo"); + + let builtin_silo = Silo::new_with_id( + *SILO_ID, + params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: "fakesilo".parse().unwrap(), + description: "fake silo".to_string(), + }, + discoverable: false, + }, + ); + + let _create_result = self.silo_create(opctx, builtin_silo).await?; + info!(opctx.log, "created built-in silo"); + + Ok(()) + } + + pub async fn silo_create( + &self, + opctx: &OpContext, + silo: Silo, + ) -> CreateResult { + use db::schema::silo::dsl; + + // TODO opctx.authorize + + let silo_id = silo.id(); + + diesel::insert_into(dsl::silo) + .values(silo) + .returning(Silo::as_returning()) + .get_result_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::Conflict( + ResourceType::Silo, + silo_id.to_string().as_str(), + ), + ) + }) + } + + pub async fn silos_list_by_id( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + use db::schema::silo::dsl; + // TODO opctx.authorize + paginated(dsl::silo, dsl::id, pagparams) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::discoverable.eq(true)) + .select(Silo::as_select()) + .load_async::(self.pool_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } + + pub async fn silos_list_by_name( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + use db::schema::silo::dsl; + // TODO opctx.authorize + paginated(dsl::silo, dsl::name, pagparams) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::discoverable.eq(true)) + .select(Silo::as_select()) + .load_async::(self.pool_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } + + pub async fn silo_fetch( + &self, + _opctx: &OpContext, + name: &Name, + ) -> LookupResult { + // TODO opctx.authorize + self.silo_lookup_noauthz(name).await + } + + pub async fn silo_lookup_noauthz( + &self, + // XXX enable when all endpoints are protected by authn + // opctx: &OpContext, + name: &Name, + ) -> LookupResult { + use db::schema::silo::dsl; + + // TODO opctx.authorize + + dsl::silo + .filter(dsl::time_deleted.is_null()) + .filter(dsl::name.eq(name.clone())) + .select(Silo::as_select()) + .get_result_async::(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Silo, + LookupType::ByName(name.as_str().to_owned()), + ), + ) + }) + } + + pub async fn silo_delete( + &self, + opctx: &OpContext, + name: &Name, + ) -> DeleteResult { + use db::schema::organization; + use db::schema::silo; + use db::schema::silo_user; + + // TODO opctx.authorize + + let (id, rcgen) = silo::dsl::silo + .filter(silo::dsl::time_deleted.is_null()) + .filter(silo::dsl::name.eq(name.clone())) + .select((silo::dsl::id, silo::dsl::rcgen)) + .get_result_async::<(Uuid, Generation)>(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Silo, + LookupType::ByName(name.to_string()), + ), + ) + })?; + + // Make sure there are no organizations present within this silo. + let org_found = diesel_pool_result_optional( + organization::dsl::organization + .filter(organization::dsl::silo_id.eq(id)) + .filter(organization::dsl::time_deleted.is_null()) + .select(organization::dsl::id) + .limit(1) + .first_async::(self.pool()) + .await, + ) + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server))?; + + if org_found.is_some() { + return Err(Error::InvalidRequest { + message: "silo to be deleted contains an organization" + .to_string(), + }); + } + + let now = Utc::now(); + let updated_rows = diesel::update(silo::dsl::silo) + .filter(silo::dsl::time_deleted.is_null()) + .filter(silo::dsl::id.eq(id)) + .filter(silo::dsl::rcgen.eq(rcgen)) + .set(silo::dsl::time_deleted.eq(now)) + .execute_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Silo, + LookupType::ById(id), + ), + ) + })?; + + if updated_rows == 0 { + return Err(Error::InvalidRequest { + message: "silo deletion failed due to concurrent modification" + .to_string(), + }); + } + + info!(opctx.log, "deleted silo {}", id); + + // If silo deletion succeeded, delete all silo users + let updated_rows = diesel::update(silo_user::dsl::silo_user) + .filter(silo_user::dsl::silo_id.eq(id)) + .set(silo_user::dsl::time_deleted.eq(now)) + .execute_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Silo, + LookupType::ById(id), + ), + ) + })?; + + info!(opctx.log, "deleted {} silo users for silo {}", updated_rows, id,); + + Ok(()) + } } /// Constructs a DataStore for use in test suites that has preloaded the @@ -3434,6 +3739,7 @@ pub async fn datastore_test( datastore.load_builtin_users(&opctx).await.unwrap(); datastore.load_builtin_roles(&opctx).await.unwrap(); datastore.load_builtin_role_asgns(&opctx).await.unwrap(); + datastore.load_builtin_silos(&opctx).await.unwrap(); // Create an OpContext with the credentials of "test-privileged" for general // testing. @@ -3469,12 +3775,16 @@ mod test { let logctx = dev::test_setup_log("test_project_creation"); let mut db = test_setup_database(&logctx.log).await; let (opctx, datastore) = datastore_test(&logctx, &db).await; - let organization = Organization::new(params::OrganizationCreate { - identity: IdentityMetadataCreateParams { - name: "org".parse().unwrap(), - description: "desc".to_string(), + let organization = Organization::new( + params::OrganizationCreate { + identity: IdentityMetadataCreateParams { + name: "org".parse().unwrap(), + description: "desc".to_string(), + }, }, - }); + *SILO_ID, + ); + let organization = datastore.organization_create(&opctx, organization).await.unwrap(); @@ -3492,6 +3802,7 @@ mod test { LookupType::ById(organization.id()), ); datastore.project_create(&opctx, &org, project).await.unwrap(); + let (_, organization_after_project_create) = datastore .organization_fetch(&opctx, organization.name()) .await @@ -3507,19 +3818,40 @@ mod test { let logctx = dev::test_setup_log("test_session_methods"); let mut db = test_setup_database(&logctx.log).await; let (_, datastore) = datastore_test(&logctx, &db).await; + let token = "a_token".to_string(); + let silo_user_id = Uuid::new_v4(); + let session = ConsoleSession { token: token.clone(), time_created: Utc::now() - Duration::minutes(5), time_last_used: Utc::now() - Duration::minutes(5), - user_id: Uuid::new_v4(), + silo_user_id, }; let _ = datastore.session_create(session.clone()).await; + // Associate silo with user + let silo_user = datastore + .silo_user_create(SiloUser::new( + Uuid::new_v4(), /* silo id */ + silo_user_id, + )) + .await + .unwrap(); + + assert_eq!( + silo_user.silo_id, + datastore + .silo_user_fetch(session.silo_user_id) + .await + .unwrap() + .silo_id, + ); + // fetch the one we just created let fetched = datastore.session_fetch(token.clone()).await.unwrap(); - assert_eq!(session.user_id, fetched.user_id); + assert_eq!(session.silo_user_id, fetched.console_session.silo_user_id); // trying to insert the same one again fails let duplicate = datastore.session_create(session.clone()).await; @@ -3531,11 +3863,15 @@ mod test { // update last used (i.e., renew token) let renewed = datastore.session_update_last_used(token.clone()).await.unwrap(); - assert!(renewed.time_last_used > session.time_last_used); + assert!( + renewed.console_session.time_last_used > session.time_last_used + ); // time_last_used change persists in DB let fetched = datastore.session_fetch(token.clone()).await.unwrap(); - assert!(fetched.time_last_used > session.time_last_used); + assert!( + fetched.console_session.time_last_used > session.time_last_used + ); // delete it and fetch should come back with nothing let delete = datastore.session_hard_delete(token.clone()).await; @@ -3545,7 +3881,7 @@ mod test { let fetched = datastore.session_fetch(token.clone()).await; assert!(matches!( fetched, - Err(Error::InternalError { internal_message: _ }) + Err(Error::ObjectNotFound { type_name: _, lookup_type: _ }) )); // deleting an already nonexistent is considered a success diff --git a/nexus/src/db/fixed_data/mod.rs b/nexus/src/db/fixed_data/mod.rs index 871a2f2f421..0137eec2ae6 100644 --- a/nexus/src/db/fixed_data/mod.rs +++ b/nexus/src/db/fixed_data/mod.rs @@ -26,12 +26,13 @@ // UUID PREFIX RESOURCE // 001de000-05e4 built-in users ("05e4" looks a bit like "user") // 001de000-1334 built-in fleet ("1334" looks like the "leet" in "fleet") -// +// 001de000-5110 built-in silo ("5110" looks like "silo") use lazy_static::lazy_static; pub mod role_assignment_builtin; pub mod role_builtin; +pub mod silo_builtin; pub mod user_builtin; lazy_static! { diff --git a/nexus/src/db/fixed_data/silo_builtin.rs b/nexus/src/db/fixed_data/silo_builtin.rs new file mode 100644 index 00000000000..6610f673c4e --- /dev/null +++ b/nexus/src/db/fixed_data/silo_builtin.rs @@ -0,0 +1,11 @@ +// 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/. + +use lazy_static::lazy_static; + +lazy_static! { + pub static ref SILO_ID: uuid::Uuid = "001de000-5110-4000-8000-000000000000" + .parse() + .expect("invalid uuid for builtin silo id"); +} diff --git a/nexus/src/db/fixed_data/user_builtin.rs b/nexus/src/db/fixed_data/user_builtin.rs index 5320fe1c0d2..a3c4d911e09 100644 --- a/nexus/src/db/fixed_data/user_builtin.rs +++ b/nexus/src/db/fixed_data/user_builtin.rs @@ -3,12 +3,14 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Built-in users +use crate::db::fixed_data::silo_builtin::SILO_ID; use lazy_static::lazy_static; use omicron_common::api; use uuid::Uuid; pub struct UserBuiltinConfig { pub id: Uuid, + pub silo_id: Uuid, pub name: api::external::Name, pub description: &'static str, } @@ -16,11 +18,15 @@ pub struct UserBuiltinConfig { impl UserBuiltinConfig { fn new_static( id: &str, + silo_id: &str, name: &str, description: &'static str, ) -> UserBuiltinConfig { UserBuiltinConfig { id: id.parse().expect("invalid uuid for builtin user id"), + silo_id: silo_id + .parse() + .expect("invalid uuid for builtin user silo id"), name: name.parse().expect("invalid name for builtin user name"), description, } @@ -35,6 +41,7 @@ lazy_static! { // "0001" is the first possible user that wouldn't be confused with // 0, or root. "001de000-05e4-4000-8000-000000000001", + &SILO_ID.to_string().as_str(), "db-init", "used for seeding initial database data", ); @@ -43,6 +50,7 @@ lazy_static! { pub static ref USER_INTERNAL_API: UserBuiltinConfig = UserBuiltinConfig::new_static( "001de000-05e4-4000-8000-000000000002", + &SILO_ID.to_string().as_str(), "internal-api", "used by Nexus when handling internal API requests", ); @@ -52,6 +60,7 @@ lazy_static! { UserBuiltinConfig::new_static( // "4ead" looks like "read" "001de000-05e4-4000-8000-000000004ead", + &SILO_ID.to_string().as_str(), "internal-read", "used by Nexus to read privileged control plane data", ); @@ -61,6 +70,7 @@ lazy_static! { UserBuiltinConfig::new_static( // "3a8a" looks a bit like "saga". "001de000-05e4-4000-8000-000000003a8a", + &SILO_ID.to_string().as_str(), "saga-recovery", "used by Nexus when recovering sagas", ); @@ -73,6 +83,7 @@ lazy_static! { UserBuiltinConfig::new_static( // "4007" looks a bit like "root". "001de000-05e4-4000-8000-000000004007", + &SILO_ID.to_string().as_str(), "test-privileged", "used for testing with all privileges", ); @@ -82,6 +93,7 @@ lazy_static! { UserBuiltinConfig::new_static( // 60001 is the decimal uid for "nobody" on Helios. "001de000-05e4-4000-8000-000000060001", + &SILO_ID.to_string().as_str(), "test-unprivileged", "used for testing with no privileges", ); diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index ebc837db711..22c3a63b514 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -9,9 +9,9 @@ use crate::db::identity::{Asset, Resource}; use crate::db::schema::{ console_session, dataset, disk, instance, metric_producer, network_interface, organization, oximeter, project, rack, region, - role_assignment_builtin, role_builtin, router_route, sled, snapshot, - update_available_artifact, user_builtin, volume, vpc, vpc_firewall_rule, - vpc_router, vpc_subnet, zpool, + role_assignment_builtin, role_builtin, router_route, silo, silo_user, sled, + snapshot, update_available_artifact, user_builtin, volume, vpc, + vpc_firewall_rule, vpc_router, vpc_subnet, zpool, }; use crate::defaults; use crate::external_api::params; @@ -907,6 +907,67 @@ impl Volume { } } +/// Describes a silo within the database. +#[derive(Queryable, Insertable, Debug, Resource, Selectable)] +#[table_name = "silo"] +pub struct Silo { + #[diesel(embed)] + identity: SiloIdentity, + + pub discoverable: bool, + + /// child resource generation number, per RFD 192 + pub rcgen: Generation, +} + +impl Silo { + /// Creates a new database Silo object. + pub fn new(params: params::SiloCreate) -> Self { + Self::new_with_id(Uuid::new_v4(), params) + } + + pub fn new_with_id(id: Uuid, params: params::SiloCreate) -> Self { + Self { + identity: SiloIdentity::new(id, params.identity), + discoverable: params.discoverable, + rcgen: Generation::new(), + } + } +} + +impl DatastoreCollection for Silo { + type CollectionId = Uuid; + type GenerationNumberColumn = silo::dsl::rcgen; + type CollectionTimeDeletedColumn = silo::dsl::time_deleted; + type CollectionIdColumn = organization::dsl::silo_id; +} + +/// Describes a silo user within the database. +#[derive(Queryable, Insertable, Debug, Selectable)] +#[table_name = "silo_user"] +pub struct SiloUser { + pub id: Uuid, + pub silo_id: Uuid, + + pub time_created: DateTime, + pub time_modified: DateTime, + pub time_deleted: Option>, +} + +impl SiloUser { + pub fn new(silo_id: Uuid, user_id: Uuid) -> Self { + let now = Utc::now(); + Self { + id: user_id, + silo_id, + + time_created: now, + time_modified: now, + time_deleted: None, + } + } +} + /// Describes an organization within the database. #[derive(Queryable, Insertable, Debug, Resource, Selectable)] #[table_name = "organization"] @@ -914,19 +975,26 @@ pub struct Organization { #[diesel(embed)] identity: OrganizationIdentity, + silo_id: Uuid, + /// child resource generation number, per RFD 192 pub rcgen: Generation, } impl Organization { /// Creates a new database Organization object. - pub fn new(params: params::OrganizationCreate) -> Self { + pub fn new(params: params::OrganizationCreate, silo_id: Uuid) -> Self { let id = Uuid::new_v4(); Self { identity: OrganizationIdentity::new(id, params.identity), + silo_id, rcgen: Generation::new(), } } + + pub fn silo_id(&self) -> Uuid { + self.silo_id + } } impl DatastoreCollection for Organization { @@ -2244,13 +2312,13 @@ pub struct ConsoleSession { pub token: String, pub time_created: DateTime, pub time_last_used: DateTime, - pub user_id: Uuid, + pub silo_user_id: Uuid, } impl ConsoleSession { - pub fn new(token: String, user_id: Uuid) -> Self { + pub fn new(token: String, silo_user_id: Uuid) -> Self { let now = Utc::now(); - Self { token, user_id, time_last_used: now, time_created: now } + Self { token, silo_user_id, time_last_used: now, time_created: now } } } diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 1cab17dd90d..036e258f7f0 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -94,9 +94,34 @@ table! { } } +table! { + silo (id) { + id -> Uuid, + name -> Text, + description -> Text, + discoverable -> Bool, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + rcgen -> Int8, + } +} + +table! { + silo_user (id) { + id -> Uuid, + silo_id -> Uuid, + + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + } +} + table! { organization (id) { id -> Uuid, + silo_id -> Uuid, name -> Text, description -> Text, time_created -> Timestamptz, @@ -167,7 +192,7 @@ table! { token -> Text, time_created -> Timestamptz, time_last_used -> Timestamptz, - user_id -> Uuid, + silo_user_id -> Uuid, } } diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs index c27f2a0dcc6..24b53515263 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -65,10 +65,10 @@ pub async fn spoof_login( .body("".into())?); // TODO: failed login response body? } - let session = nexus - // TODO: obviously - .session_create(user_id.unwrap()) - .await?; + let user_id = user_id.unwrap(); + + let session = nexus.session_create(user_id).await?; + Ok(Response::builder() .status(StatusCode::OK) .header( diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 48440bd9324..63381911e99 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -11,7 +11,7 @@ use crate::ServerContext; use super::{ console_api, params, views::{ - Organization, Project, Rack, Role, Sled, Snapshot, User, Vpc, + Organization, Project, Rack, Role, Silo, Sled, Snapshot, User, Vpc, VpcRouter, VpcSubnet, }, }; @@ -70,6 +70,11 @@ type NexusApiDescription = ApiDescription>; /// Returns a description of the external nexus API pub fn external_api() -> NexusApiDescription { fn register_endpoints(api: &mut NexusApiDescription) -> Result<(), String> { + api.register(silos_get)?; + api.register(silos_post)?; + api.register(silos_get_silo)?; + api.register(silos_delete_silo)?; + api.register(organizations_get)?; api.register(organizations_post)?; api.register(organizations_get_organization)?; @@ -213,12 +218,125 @@ pub fn external_api() -> NexusApiDescription { // clients. Client generators use operationId to name API methods, so changing // a function name is a breaking change from a client perspective. +// TODO authz for silo endpoints + +// List all silos (that are discoverable). +#[endpoint { + method = GET, + path = "/silos", + tags = ["silos"], +}] +async fn silos_get( + rqctx: Arc>>, + query_params: Query, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let query = query_params.into_inner(); + let params = ScanByNameOrId::from_query(&query)?; + let field = pagination_field_for_scan_params(params); + + let silos = match field { + PagField::Id => { + let page_selector = data_page_params_nameid_id(&rqctx, &query)?; + nexus.silos_list_by_id(&opctx, &page_selector).await? + } + + PagField::Name => { + let page_selector = + data_page_params_nameid_name(&rqctx, &query)? + .map_name(|n| Name::ref_cast(n)); + nexus.silos_list_by_name(&opctx, &page_selector).await? + } + } + .into_iter() + .map(|p| p.into()) + .collect(); + Ok(HttpResponseOk(ScanByNameOrId::results_page(&query, silos)?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create a new silo. +#[endpoint { + method = POST, + path = "/silos", + tags = ["silos"], +}] +async fn silos_post( + rqctx: Arc>>, + new_silo_params: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let silo = + nexus.silo_create(&opctx, new_silo_params.into_inner()).await?; + Ok(HttpResponseCreated(silo.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Path parameters for Silo requests +#[derive(Deserialize, JsonSchema)] +struct SiloPathParam { + /// The silo's unique name. + silo_name: Name, +} + +/// Fetch a specific silo +#[endpoint { + method = GET, + path = "/silos/{silo_name}", + tags = ["silos"], +}] +async fn silos_get_silo( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let silo_name = &path.silo_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let silo = nexus.silo_fetch(&opctx, &silo_name).await?; + Ok(HttpResponseOk(silo.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete a specific silo. +#[endpoint { + method = DELETE, + path = "/silos/{silo_name}", + tags = ["silos"], +}] +async fn silos_delete_silo( + rqctx: Arc>>, + path_params: Path, +) -> Result { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let params = path_params.into_inner(); + let silo_name = ¶ms.silo_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + nexus.silo_delete(&opctx, &silo_name).await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /// List all organizations. #[endpoint { method = GET, path = "/organizations", tags = ["organizations"], - }] +}] async fn organizations_get( rqctx: Arc>>, query_params: Query, diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 2775df390a4..62c6843d89f 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -13,6 +13,17 @@ use serde::{Deserialize, Serialize}; use std::net::IpAddr; use uuid::Uuid; +// Silos + +/// Create-time parameters for a [`Silo`](crate::external_api::views::Silo) +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SiloCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + pub discoverable: bool, +} + // ORGANIZATIONS /// Create-time parameters for an [`Organization`](crate::external_api::views::Organization) diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index 0a9cea9c55b..c02b6314a11 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -32,6 +32,12 @@ "url": "http://oxide.computer/docs/#xxx" } }, + "silos": { + "description": "Silos represent a logical partition of users and resources.", + "external_docs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, "organizations": { "description": "Organizations represent a subset of users and projects in an Oxide deployment.", "external_docs": { diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 7cdcd11f416..e6d57afabd1 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -17,6 +17,25 @@ use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use uuid::Uuid; +// SILOS + +/// Client view of a ['Silo'] +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct Silo { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// A silo where discoverable is false can be retrieved only by its id - it + /// will not be part of the "list all silos" output. + pub discoverable: bool, +} + +impl Into for model::Silo { + fn into(self) -> Silo { + Silo { identity: self.identity(), discoverable: self.discoverable } + } +} + // ORGANIZATIONS /// Client view of an [`Organization`] @@ -24,6 +43,7 @@ use uuid::Uuid; pub struct Organization { #[serde(flatten)] pub identity: IdentityMetadata, + // Important: Silo ID does not get presented to user } impl From for Organization { @@ -227,7 +247,7 @@ pub struct SessionUser { impl From for SessionUser { fn from(actor: authn::Actor) -> Self { - Self { id: actor.0 } + Self { id: actor.id } } } diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 5d76015800e..23fc4cc0ee7 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -13,6 +13,7 @@ use crate::db::identity::{Asset, Resource}; use crate::db::model::DatasetKind; use crate::db::model::Name; use crate::db::model::RouterRoute; +use crate::db::model::SiloUser; use crate::db::model::VpcRouter; use crate::db::model::VpcRouterKind; use crate::db::model::VpcSubnet; @@ -105,6 +106,12 @@ pub trait TestInterfaces { &self, session: db::model::ConsoleSession, ) -> CreateResult; + + async fn silo_user_create( + &self, + silo_id: Uuid, + silo_user_id: Uuid, + ) -> CreateResult; } pub static BASE_ARTIFACT_DIR: &str = "/var/tmp/oxide_artifacts"; @@ -480,6 +487,51 @@ impl Nexus { }) } + /* + * Silos + */ + + pub async fn silo_create( + &self, + opctx: &OpContext, + new_silo_params: params::SiloCreate, + ) -> CreateResult { + let silo = db::model::Silo::new(new_silo_params); + self.db_datastore.silo_create(opctx, silo).await + } + + pub async fn silo_fetch( + &self, + opctx: &OpContext, + name: &Name, + ) -> LookupResult { + self.db_datastore.silo_fetch(opctx, name).await + } + + pub async fn silos_list_by_name( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + self.db_datastore.silos_list_by_name(opctx, pagparams).await + } + + pub async fn silos_list_by_id( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + self.db_datastore.silos_list_by_id(opctx, pagparams).await + } + + pub async fn silo_delete( + &self, + opctx: &OpContext, + name: &Name, + ) -> DeleteResult { + self.db_datastore.silo_delete(opctx, name).await + } + // Organizations pub async fn organization_create( @@ -487,7 +539,9 @@ impl Nexus { opctx: &OpContext, new_organization: ¶ms::OrganizationCreate, ) -> CreateResult { - let db_org = db::model::Organization::new(new_organization.clone()); + let silo_id = opctx.authn.silo_required()?; + let db_org = + db::model::Organization::new(new_organization.clone(), silo_id); self.db_datastore.organization_create(opctx, db_org).await } @@ -2803,7 +2857,7 @@ impl Nexus { pub async fn session_fetch( &self, token: String, - ) -> LookupResult { + ) -> LookupResult { self.db_datastore.session_fetch(token).await } @@ -2811,8 +2865,15 @@ impl Nexus { &self, user_id: Uuid, ) -> CreateResult { + if !self.login_allowed(user_id).await? { + return Err(Error::Unauthenticated { + internal_message: "User not allowed to login".to_string(), + }); + } + let session = db::model::ConsoleSession::new(generate_session_token(), user_id); + Ok(self.db_datastore.session_create(session).await?) } @@ -2820,7 +2881,7 @@ impl Nexus { pub async fn session_update_last_used( &self, token: String, - ) -> UpdateResult { + ) -> UpdateResult { Ok(self.db_datastore.session_update_last_used(token).await?) } @@ -3069,6 +3130,41 @@ impl Nexus { })?; Ok(body) } + + async fn login_allowed(&self, silo_user_id: Uuid) -> Result { + // Was this silo user deleted? + let fetch_result = + self.db_datastore.silo_user_fetch(silo_user_id).await; + + match fetch_result { + Err(e) => { + match e { + Error::ObjectNotFound { type_name: _, lookup_type: _ } => { + // if the silo user was deleted, they're not allowed to + // log in :) + return Ok(false); + } + + _ => { + return Err(e); + } + } + } + + Ok(_) => { + // they're allowed + } + } + + Ok(true) + } + + pub async fn silo_user_fetch( + &self, + silo_user_id: Uuid, + ) -> LookupResult { + self.db_datastore.silo_user_fetch(silo_user_id).await + } } fn generate_session_token() -> String { @@ -3126,4 +3222,13 @@ impl TestInterfaces for Nexus { ) -> CreateResult { Ok(self.db_datastore.session_create(session).await?) } + + async fn silo_user_create( + &self, + silo_id: Uuid, + silo_user_id: Uuid, + ) -> CreateResult { + let silo_user = SiloUser::new(silo_id, silo_user_id); + Ok(self.db_datastore.silo_user_create(silo_user).await?) + } } diff --git a/nexus/src/populate.rs b/nexus/src/populate.rs index 4dbd09a294e..944e5308162 100644 --- a/nexus/src/populate.rs +++ b/nexus/src/populate.rs @@ -53,10 +53,14 @@ async fn populate( async { datastore.load_builtin_role_asgns(opctx).await.map(|_| ()) } .boxed() }; + let populate_silos = || { + async { datastore.load_builtin_silos(opctx).await.map(|_| ()) }.boxed() + }; let populators = [ Populator { name: "users", func: &populate_users }, Populator { name: "roles", func: &populate_roles }, Populator { name: "role assignments", func: &populate_role_asgns }, + Populator { name: "silos", func: &populate_silos }, ]; for p in populators { diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index fca1073bf30..f3e16f0e0bd 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -403,6 +403,7 @@ impl TestResponse { pub enum AuthnMode { UnprivilegedUser, PrivilegedUser, + Session(String), } /// Helper for constructing requests to Nexus's external API @@ -431,15 +432,32 @@ impl<'a> NexusRequest<'a> { /// `mode` pub fn authn_as(mut self, mode: AuthnMode) -> Self { use omicron_nexus::authn; - let header_value = match mode { - AuthnMode::UnprivilegedUser => authn::USER_TEST_UNPRIVILEGED.id, - AuthnMode::PrivilegedUser => authn::USER_TEST_PRIVILEGED.id, - }; - self.request_builder = self.request_builder.header( - &http::header::AUTHORIZATION, - spoof::make_header_value(header_value).0.encode(), - ); + match mode { + AuthnMode::UnprivilegedUser | AuthnMode::PrivilegedUser => { + let header_value = match mode { + AuthnMode::UnprivilegedUser => { + authn::USER_TEST_UNPRIVILEGED.id + } + AuthnMode::PrivilegedUser => authn::USER_TEST_PRIVILEGED.id, + _ => { + panic!("unreachable!") + } + }; + + self.request_builder = self.request_builder.header( + &http::header::AUTHORIZATION, + spoof::make_header_value(header_value).0.encode(), + ); + } + AuthnMode::Session(session_token) => { + self.request_builder = self.request_builder.header( + &http::header::COOKIE, + format!("session={}", session_token), + ); + } + } + self } @@ -507,6 +525,20 @@ impl<'a> NexusRequest<'a> { ) } + pub fn expect_failure_with_body( + testctx: &'a ClientTestContext, + expected_status: http::StatusCode, + method: http::Method, + uri: &str, + body: &B, + ) -> Self { + NexusRequest::new( + RequestBuilder::new(testctx, method, uri) + .body(Some(body)) + .expect_status(Some(expected_status)), + ) + } + /// Iterates a collection (like `dropshot::test_util::iter_collection`) /// using authenticated requests. pub async fn iter_collection_authn( diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 3f7068e77d3..9329c9cb4e2 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -19,7 +19,7 @@ use omicron_common::api::external::InstanceCpuCount; use omicron_nexus::crucible_agent_client::types::State as RegionState; use omicron_nexus::external_api::params; use omicron_nexus::external_api::views::{ - Organization, Project, Vpc, VpcRouter, + Organization, Project, Silo, Vpc, VpcRouter, }; use omicron_sled_agent::sim::SledAgent; use std::sync::Arc; @@ -59,6 +59,25 @@ where .unwrap() } +pub async fn create_silo( + client: &ClientTestContext, + silo_name: &str, + discoverable: bool, +) -> Silo { + object_create( + client, + "/silos", + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: silo_name.parse().unwrap(), + description: "a silo".to_string(), + }, + discoverable, + }, + ) + .await +} + pub async fn create_organization( client: &ClientTestContext, organization_name: &str, diff --git a/nexus/tests/integration_tests/authn_http.rs b/nexus/tests/integration_tests/authn_http.rs index b48847ee237..1455c1b34ea 100644 --- a/nexus/tests/integration_tests/authn_http.rs +++ b/nexus/tests/integration_tests/authn_http.rs @@ -104,17 +104,20 @@ async fn test_authn_session_cookie() { Box + 'static>, > = vec![Box::new(session_cookie::HttpAuthnSessionCookie)]; let valid_session = FakeSession { - user_id: Uuid::new_v4(), + silo_user_id: Uuid::new_v4(), + silo_id: Uuid::new_v4(), time_last_used: Utc::now() - Duration::seconds(5), time_created: Utc::now() - Duration::seconds(5), }; let idle_expired_session = FakeSession { - user_id: Uuid::new_v4(), + silo_user_id: Uuid::new_v4(), + silo_id: Uuid::new_v4(), time_last_used: Utc::now() - Duration::hours(2), time_created: Utc::now() - Duration::hours(3), }; let abs_expired_session = FakeSession { - user_id: Uuid::new_v4(), + silo_user_id: Uuid::new_v4(), + silo_id: Uuid::new_v4(), time_last_used: Utc::now(), time_created: Utc::now() - Duration::hours(10), }; @@ -138,7 +141,7 @@ async fn test_authn_session_cookie() { whoami_request(None, Some(valid_header), &testctx).await.unwrap(), WhoamiResponse { authenticated: true, - actor: Some(valid_session.user_id.to_string()), + actor: Some(valid_session.silo_user_id.to_string()), schemes_tried: tried_cookie.clone(), } ); @@ -307,14 +310,18 @@ struct WhoamiServerState { #[derive(Clone, Copy)] struct FakeSession { - user_id: Uuid, + silo_user_id: Uuid, + silo_id: Uuid, time_created: DateTime, time_last_used: DateTime, } impl session_cookie::Session for FakeSession { - fn user_id(&self) -> Uuid { - self.user_id + fn silo_user_id(&self) -> Uuid { + self.silo_user_id + } + fn silo_id(&self) -> Uuid { + self.silo_id } fn time_created(&self) -> DateTime { self.time_created @@ -380,7 +387,7 @@ async fn whoami_get( ) -> Result, dropshot::HttpError> { let whoami_state = rqctx.context(); let authn = whoami_state.authn.authn_request(&rqctx).await?; - let actor = authn.actor().map(|a| a.0.to_string()); + let actor = authn.actor().map(|a| a.id.to_string()); let authenticated = actor.is_some(); let schemes_tried = authn.schemes_tried().iter().map(|s| s.to_string()).collect(); diff --git a/nexus/tests/integration_tests/basic.rs b/nexus/tests/integration_tests/basic.rs index 11089d06130..9005559bbe2 100644 --- a/nexus/tests/integration_tests/basic.rs +++ b/nexus/tests/integration_tests/basic.rs @@ -51,116 +51,116 @@ async fn test_basic_failures(cptestctx: &ControlPlaneTestContext) { .expect_err("expected error"); assert_eq!("Not Found", error.message); - // Error case: GET /organizations/test-org/projects/nonexistent (a possible - // value that does not exist inside a collection that does exist) - let error = client - .make_request( - Method::GET, - "/organizations/test-org/projects/nonexistent", - None as Option<()>, - StatusCode::NOT_FOUND, - ) - .await - .expect_err("expected error"); - assert_eq!("not found: project with name \"nonexistent\"", error.message); - - // Error case: GET /organizations/test-org/projects/-invalid-name - // TODO-correctness is 400 the right error code here or is 404 more - // appropriate? - let error = client - .make_request( - Method::GET, - "/organizations/test-org/projects/-invalid-name", - None as Option<()>, - StatusCode::BAD_REQUEST, - ) - .await - .expect_err("expected error"); - assert_eq!( - "bad parameter in URL path: name must begin with an ASCII lowercase \ - character", - error.message - ); - - // Error case: PUT /organizations/test-org/projects - let error = client - .make_request( - Method::PUT, - "/organizations/test-org/projects", - None as Option<()>, - StatusCode::METHOD_NOT_ALLOWED, - ) - .await - .expect_err("expected error"); - assert_eq!("Method Not Allowed", error.message); - - // Error case: DELETE /organizations/test-org/projects - let error = client - .make_request( - Method::DELETE, - "/organizations/test-org/projects", - None as Option<()>, - StatusCode::METHOD_NOT_ALLOWED, - ) - .await - .expect_err("expected error"); - assert_eq!("Method Not Allowed", error.message); - - // Error case: list instances in a nonexistent project. - let error = client - .make_request_with_body( - Method::GET, - "/organizations/test-org/projects/nonexistent/instances", - "".into(), - StatusCode::NOT_FOUND, - ) - .await - .expect_err("expected error"); - assert_eq!("not found: project with name \"nonexistent\"", error.message); + struct TestCase<'a> { + method: http::Method, + uri: &'a str, + expected_code: http::StatusCode, + expected_error: &'a str, + body: Option, + } - // Error case: fetch an instance in a nonexistent project. - let error = client - .make_request_with_body( - Method::GET, - "/organizations/test-org/projects/nonexistent/instances/my-instance", - "".into(), - StatusCode::NOT_FOUND, - ) - .await - .expect_err("expected error"); - assert_eq!("not found: project with name \"nonexistent\"", error.message); + let test_cases = vec![ + // Error case: GET /organizations/test-org/projects/nonexistent (a + // possible value that does not exist inside a collection that does + // exist) from an authorized user results in a 404. + TestCase { + method: Method::GET, + uri: "/organizations/test-org/projects/nonexistent", + expected_code: StatusCode::NOT_FOUND, + expected_error: "not found: project with name \"nonexistent\"", + body: None, + }, + // Error case: GET /organizations/test-org/projects/-invalid-name + // TODO-correctness is 400 the right error code here or is 404 more + // appropriate? + TestCase { + method: Method::GET, + uri: "/organizations/test-org/projects/-invalid-name", + expected_code: StatusCode::BAD_REQUEST, + expected_error: "bad parameter in URL path: name must begin with \ + an ASCII lowercase character", + body: None, + }, + // Error case: PUT /organizations/test-org/projects + TestCase { + method: Method::PUT, + uri: "/organizations/test-org/projects", + expected_code: StatusCode::METHOD_NOT_ALLOWED, + expected_error: "Method Not Allowed", + body: None, + }, + // Error case: DELETE /organizations/test-org/projects + TestCase { + method: Method::DELETE, + uri: "/organizations/test-org/projects", + expected_code: StatusCode::METHOD_NOT_ALLOWED, + expected_error: "Method Not Allowed", + body: None, + }, + // Error case: list instances in a nonexistent project + TestCase { + method: Method::GET, + uri: "/organizations/test-org/projects/nonexistent/instances", + expected_code: StatusCode::NOT_FOUND, + expected_error: "not found: project with name \"nonexistent\"", + body: Some("".into()), + }, + // Error case: fetch an instance in a nonexistent project + TestCase { + method: Method::GET, + uri: "/organizations/test-org/projects/nonexistent/instances/my-instance", + expected_code: StatusCode::NOT_FOUND, + expected_error: "not found: project with name \"nonexistent\"", + body: Some("".into()), + }, + // Error case: fetch an instance with an invalid name + TestCase { + method: Method::GET, + uri: "/organizations/test-org/projects/nonexistent/instances/my_instance", + expected_code: StatusCode::BAD_REQUEST, + expected_error: "bad parameter in URL path: name contains \ + invalid character: \"_\" (allowed characters are lowercase \ + ASCII, digits, and \"-\")", + body: Some("".into()), + }, + // Error case: delete an instance with an invalid name + TestCase { + method: Method::DELETE, + uri: "/organizations/test-org/projects/nonexistent/instances/my_instance", + expected_code: StatusCode::BAD_REQUEST, + expected_error: "bad parameter in URL path: name contains \ + invalid character: \"_\" (allowed characters are lowercase \ + ASCII, digits, and \"-\")", + body: Some("".into()), + }, + ]; - // Error case: fetch an instance with an invalid name. - let error = client - .make_request_with_body( - Method::GET, - "/organizations/test-org/projects/nonexistent/instances/my_instance", - "".into(), - StatusCode::BAD_REQUEST, - ) + for test_case in test_cases { + let error = if let Some(body) = test_case.body { + NexusRequest::expect_failure_with_body( + client, + test_case.expected_code, + test_case.method, + test_case.uri, + &body, + ) + } else { + NexusRequest::expect_failure( + client, + test_case.expected_code, + test_case.method, + test_case.uri, + ) + } + .authn_as(AuthnMode::PrivilegedUser) + .execute() .await - .expect_err("expected error"); - assert_eq!( - "bad parameter in URL path: name contains invalid character: \"_\" \ - (allowed characters are lowercase ASCII, digits, and \"-\")", - error.message - ); + .unwrap() + .parsed_body::() + .unwrap(); - // Error case: delete an instance with an invalid name. - let error = client - .make_request_with_body( - Method::DELETE, - "/organizations/test-org/projects/nonexistent/instances/my_instance", - "".into(), - StatusCode::BAD_REQUEST, - ) - .await - .expect_err("expected error"); - assert_eq!( - "bad parameter in URL path: name contains invalid character: \"_\" \ - (allowed characters are lowercase ASCII, digits, and \"-\")", - error.message - ); + assert_eq!(test_case.expected_error, error.message); + } } #[nexus_test] diff --git a/nexus/tests/integration_tests/disks.rs b/nexus/tests/integration_tests/disks.rs index e2ab7a7978e..d32ccc52cac 100644 --- a/nexus/tests/integration_tests/disks.rs +++ b/nexus/tests/integration_tests/disks.rs @@ -81,9 +81,16 @@ async fn test_disk_not_found_before_creation( // Make sure we get a 404 if we fetch one. let disk_url = format!("{}/{}", disks_url, DISK_NAME); - let error = client - .make_request_error(Method::GET, &disk_url, StatusCode::NOT_FOUND) - .await; + let error = NexusRequest::new( + RequestBuilder::new(client, Method::GET, &disk_url) + .expect_status(Some(StatusCode::NOT_FOUND)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("unexpected success") + .parsed_body::() + .unwrap(); assert_eq!( error.message, format!("not found: disk with name \"{}\"", DISK_NAME) @@ -222,9 +229,19 @@ async fn test_disk_create_attach_detach_delete( assert_eq!(disks_list(&client, &disks_url).await.len(), 0); // We shouldn't find it if we request it explicitly. - let error = client - .make_request_error(Method::GET, &disk_url, StatusCode::NOT_FOUND) - .await; + let error = NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::GET, + &disk_url, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body::() + .unwrap(); + assert_eq!( error.message, format!("not found: disk with name \"{}\"", DISK_NAME) diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 5473cae865b..6c9ba5a9047 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -15,6 +15,7 @@ mod oximeter; mod projects; mod roles_builtin; mod router_routes; +mod silos; mod subnet_allocation; mod timeseries; mod unauthorized; diff --git a/nexus/tests/integration_tests/organizations.rs b/nexus/tests/integration_tests/organizations.rs index 915a8b63852..bd5624736e8 100644 --- a/nexus/tests/integration_tests/organizations.rs +++ b/nexus/tests/integration_tests/organizations.rs @@ -45,13 +45,16 @@ async fn test_organizations(cptestctx: &ControlPlaneTestContext) { assert_eq!(organization.identity.name, o2_name); // Verify requesting a non-existent organization fails - client - .make_request_error( - Method::GET, - "/organizations/fake-org", - StatusCode::NOT_FOUND, - ) - .await; + NexusRequest::expect_failure( + &client, + StatusCode::NOT_FOUND, + Method::GET, + &"/organizations/fake-org", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); // Verify GET /organizations works let organizations = diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs new file mode 100644 index 00000000000..9dba23461de --- /dev/null +++ b/nexus/tests/integration_tests/silos.rs @@ -0,0 +1,142 @@ +// 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/. + +use uuid::Uuid; + +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; +use omicron_nexus::external_api::views::{Organization, Silo}; +use omicron_nexus::TestInterfaces as _; + +use http::method::Method; +use http::StatusCode; +use nexus_test_utils::resource_helpers::{ + create_organization, create_silo, objects_list_page_authz, +}; + +use nexus_test_utils::ControlPlaneTestContext; +use nexus_test_utils_macros::nexus_test; + +#[nexus_test] +async fn test_silos(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.apictx.nexus; + + // Create two silos: one discoverable, one not + create_silo(&client, "discoverable", true).await; + create_silo(&client, "hidden", false).await; + + // Verify GET /silos/{silo} works for both discoverable and not + let discoverable_url = "/silos/discoverable"; + let hidden_url = "/silos/hidden"; + + let silo: Silo = NexusRequest::object_get(&client, &discoverable_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + assert_eq!(silo.identity.name, "discoverable"); + + let silo: Silo = NexusRequest::object_get(&client, &hidden_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request") + .parsed_body() + .unwrap(); + assert_eq!(silo.identity.name, "hidden"); + + // Verify 404 if silo doesn't exist + NexusRequest::expect_failure( + &client, + StatusCode::NOT_FOUND, + Method::GET, + &"/silos/testpost", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Verify GET /silos only returns discoverable silos + let silos = objects_list_page_authz::(client, "/silos").await.items; + assert_eq!(silos.len(), 1); + assert_eq!(silos[0].identity.name, "discoverable"); + + // Create a new user in the discoverable silo, then create a console session + let new_silo_user = nexus + .silo_user_create( + silos[0].identity.id, /* silo id */ + Uuid::new_v4(), /* silo user id */ + ) + .await + .unwrap(); + + let session = nexus.session_create(new_silo_user.id).await.unwrap(); + + // Create organization with built-in user auth + // Note: this currently goes to the built-in silo! + create_organization(&client, "someorg").await; + + // Verify GET /organizations works with built-in user auth + let organizations = + objects_list_page_authz::(client, "/organizations") + .await + .items; + assert_eq!(organizations.len(), 1); + assert_eq!(organizations[0].identity.name, "someorg"); + + // TODO: uncomment when silo users can have role assignments + /* + // Verify GET /organizations doesn't list anything if authing under + // different silo. + let organizations = + objects_list_page_authz_with_session::( + client, "/organizations", &session, + ) + .await + .items; + assert_eq!(organizations.len(), 0); + */ + + // Verify DELETE doesn't work if organizations exist + // TODO: put someorg in discoverable silo, not built-in + NexusRequest::expect_failure( + &client, + StatusCode::BAD_REQUEST, + Method::DELETE, + &"/silos/fakesilo", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Delete organization + NexusRequest::object_delete(&client, &"/organizations/someorg") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Verify silo DELETE works + NexusRequest::object_delete(&client, &"/silos/discoverable") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); + + // Verify silo user was also deleted + nexus + .silo_user_fetch(new_silo_user.id) + .await + .expect_err("unexpected success"); + + // Verify new user's console session isn't valid anymore. + nexus + .session_fetch(session.token.clone()) + .await + .expect_err("unexpected success"); +} diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 2ddef25a4c7..98799db4e03 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -85,6 +85,13 @@ OPERATION ID URL PATH sagas_get /sagas sagas_get_saga /sagas/{saga_id} +API operations found with tag "silos" +OPERATION ID URL PATH +silos_delete_silo /silos/{silo_name} +silos_get /silos +silos_get_silo /silos/{silo_name} +silos_post /silos + API operations found with tag "sleds" OPERATION ID URL PATH hardware_sleds_get /hardware/sleds diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 244106258bd..38d2cb5302b 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,6 +1,7 @@ API endpoints with no coverage in authz tests: instance_network_interfaces_delete_interface (delete "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces/{interface_name}") project_snapshots_delete_snapshot (delete "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}") +silos_delete_silo (delete "/silos/{silo_name}") instance_disks_get (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/disks") instance_network_interfaces_get (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces") instance_network_interfaces_get_interface (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces/{interface_name}") @@ -8,7 +9,10 @@ project_snapshots_get (get "/organizations/{organization_n project_snapshots_get_snapshot (get "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}") subnet_network_interfaces_get (get "/organizations/{organization_name}/projects/{project_name}/vpcs/{vpc_name}/subnets/{subnet_name}/network-interfaces") session_me (get "/session/me") +silos_get (get "/silos") +silos_get_silo (get "/silos/{silo_name}") spoof_login (post "/login") logout (post "/logout") instance_network_interfaces_post (post "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces") project_snapshots_post (post "/organizations/{organization_name}/projects/{project_name}/snapshots") +silos_post (post "/silos") diff --git a/openapi/nexus.json b/openapi/nexus.json index e14c22ff713..2ac7a7d514c 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -3990,6 +3990,169 @@ } } }, + "/silos": { + "get": { + "tags": [ + "silos" + ], + "operationId": "silos_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retreive the subsequent page", + "schema": { + "nullable": true, + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameOrIdSortMode" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiloResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + }, + "post": { + "tags": [ + "silos" + ], + "summary": "Create a new silo.", + "operationId": "silos_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiloCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Silo" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/silos/{silo_name}": { + "get": { + "tags": [ + "silos" + ], + "summary": "Fetch a specific silo", + "operationId": "silos_get_silo", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Silo" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "silos" + ], + "summary": "Delete a specific silo.", + "operationId": "silos_delete_silo", + "parameters": [ + { + "in": "path", + "name": "silo_name", + "description": "The silo's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/timeseries/schema": { "get": { "tags": [ @@ -5776,6 +5939,92 @@ "id" ] }, + "Silo": { + "description": "Client view of a ['Silo']", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "discoverable": { + "description": "A silo where discoverable is false can be retrieved only by its id - it will not be part of the \"list all silos\" output.", + "type": "boolean" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "discoverable", + "id", + "name", + "time_created", + "time_modified" + ] + }, + "SiloCreate": { + "description": "Create-time parameters for a [`Silo`](crate::external_api::views::Silo)", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "discoverable": { + "type": "boolean" + }, + "name": { + "$ref": "#/components/schemas/Name" + } + }, + "required": [ + "description", + "discoverable", + "name" + ] + }, + "SiloResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/Silo" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "Sled": { "description": "Client view of an [`Sled`]", "type": "object", @@ -7040,6 +7289,13 @@ "url": "http://oxide.computer/docs/#xxx" } }, + { + "name": "silos", + "description": "Silos represent a logical partition of users and resources.", + "externalDocs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, { "name": "sleds", "description": "This tag should be moved into hardware", From 10403006356832b1a3611d9ab30f66f49e712df2 Mon Sep 17 00:00:00 2001 From: James MacMahon Date: Fri, 25 Mar 2022 17:03:31 -0400 Subject: [PATCH 2/2] bad merge --- nexus/src/nexus.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 2139511a137..ee0cb9c3f97 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -3368,4 +3368,5 @@ impl TestInterfaces for Nexus { ) -> CreateResult { let silo_user = SiloUser::new(silo_id, silo_user_id); Ok(self.db_datastore.silo_user_create(silo_user).await?) + } }