diff --git a/common/src/api/external/error.rs b/common/src/api/external/error.rs index 6e4fcf77431..2e8343a22c6 100644 --- a/common/src/api/external/error.rs +++ b/common/src/api/external/error.rs @@ -74,6 +74,8 @@ pub enum LookupType { ByName(String), /** a specific id was requested */ ById(Uuid), + /** a session token was requested */ + BySessionToken(String), } impl LookupType { @@ -180,6 +182,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 a890106e10b..f2c241b0f2b 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -564,6 +564,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 531f37f26f0..0a018beb130 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, @@ -707,9 +751,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 863106b80dc..6fde989032d 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; } @@ -104,7 +105,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(); @@ -188,13 +190,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 @@ -277,6 +284,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), }, @@ -301,6 +310,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), }, @@ -326,7 +337,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 c6c9df3661b..9cc867c9839 100644 --- a/nexus/src/authn/mod.rs +++ b/nexus/src/authn/mod.rs @@ -32,6 +32,7 @@ pub use crate::db::fixed_data::user_builtin::USER_INTERNAL_API; 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; @@ -63,17 +64,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. @@ -88,23 +97,31 @@ 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 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(), } } @@ -118,7 +135,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, + ) } } @@ -141,19 +161,19 @@ 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_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); } } @@ -179,7 +199,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 25fc6201ec5..5042c0b0bd3 100644 --- a/nexus/src/context.rs +++ b/nexus/src/context.rs @@ -11,9 +11,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; @@ -280,7 +279,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()); @@ -501,7 +501,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() @@ -527,14 +527,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 39c1f00d0a0..500ce51775a 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -34,6 +34,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, ConnectionError, ConnectionManager, @@ -73,9 +74,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, @@ -544,20 +545,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 @@ -594,9 +600,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 @@ -621,6 +633,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; @@ -2670,19 +2683,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( @@ -2707,10 +2732,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()) @@ -2721,7 +2746,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 @@ -2817,6 +2857,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) @@ -2828,6 +2880,7 @@ impl DataStore { public_error_from_diesel_pool(e, ErrorHandler::Server) })?; info!(opctx.log, "created {} built-in users", count); + Ok(()) } @@ -3040,6 +3093,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 @@ -3065,6 +3370,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. @@ -3098,12 +3404,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(); @@ -3121,6 +3431,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 @@ -3136,19 +3447,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; @@ -3160,11 +3492,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; @@ -3174,7 +3510,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 0a81e1a8000..afec9c8be84 100644 --- a/nexus/src/db/fixed_data/mod.rs +++ b/nexus/src/db/fixed_data/mod.rs @@ -27,12 +27,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 ebe65a295ab..d751feb8946 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( // "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", ); @@ -64,6 +73,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", ); @@ -73,6 +83,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 8870ff0b6f1..565dd0bf172 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -9,7 +9,7 @@ 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, + role_assignment_builtin, role_builtin, router_route, silo, silo_user, sled, update_available_artifact, user_builtin, volume, vpc, vpc_firewall_rule, vpc_router, vpc_subnet, zpool, }; @@ -778,6 +778,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"] @@ -785,19 +846,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 { @@ -2048,13 +2116,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 ba665e533fb..e80181fe97c 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -78,9 +78,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, @@ -151,7 +176,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 2c9b09fd8e5..f3de02d8c67 100644 --- a/nexus/src/external_api/console_api.rs +++ b/nexus/src/external_api/console_api.rs @@ -67,10 +67,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 b4b481f8601..99b6456daf7 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -12,7 +12,9 @@ use crate::ServerContext; use super::{ console_api, params, - views::{Organization, Project, Rack, Role, Sled, User, Vpc, VpcSubnet}, + views::{ + Organization, Project, Rack, Role, Silo, Sled, User, Vpc, VpcSubnet, + }, }; use crate::context::OpContext; use dropshot::ApiDescription; @@ -70,6 +72,11 @@ type NexusApiDescription = ApiDescription>; */ 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)?; @@ -203,6 +210,129 @@ pub fn external_api() -> NexusApiDescription { * 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. */ @@ -210,7 +340,7 @@ pub fn external_api() -> NexusApiDescription { 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 40f37ec63e4..15e916c95fe 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -14,6 +14,21 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; 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 */ diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 3958a825083..53321840604 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -18,6 +18,29 @@ 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 */ @@ -29,6 +52,7 @@ use uuid::Uuid; pub struct Organization { #[serde(flatten)] pub identity: IdentityMetadata, + // Important: Silo ID does not get presented to user } impl Into for model::Organization { @@ -205,7 +229,7 @@ pub struct SessionUser { impl Into for authn::Actor { fn into(self) -> SessionUser { - SessionUser { id: self.0 } + SessionUser { id: self.id } } } diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 4adb092b7d7..a6d285ee304 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -14,6 +14,7 @@ use crate::db; use crate::db::identity::{Asset, Resource}; use crate::db::model::DatasetKind; use crate::db::model::Name; +use crate::db::model::SiloUser; use crate::db::subnet_allocation::SubnetError; use crate::defaults; use crate::external_api::params; @@ -108,6 +109,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"; @@ -494,6 +501,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 */ @@ -503,7 +555,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 } @@ -2625,7 +2679,7 @@ impl Nexus { pub async fn session_fetch( &self, token: String, - ) -> LookupResult { + ) -> LookupResult { self.db_datastore.session_fetch(token).await } @@ -2633,8 +2687,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?) } @@ -2642,7 +2703,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?) } @@ -2878,6 +2939,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 { @@ -2935,4 +3031,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 50c923f6adc..70558fe936d 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 91bcb544556..79112e46550 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -380,6 +380,7 @@ where } /// Represents a response from an HTTP server +#[derive(Debug)] pub struct TestResponse { pub status: http::StatusCode, pub headers: http::HeaderMap, @@ -403,6 +404,7 @@ impl TestResponse { pub enum AuthnMode { UnprivilegedUser, PrivilegedUser, + Session(String), } /// Helper for constructing requests to Nexus's external API @@ -431,15 +433,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 +526,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 ea004cd5a88..0341213705c 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_common::api::external::VpcRouter; 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}; +use omicron_nexus::external_api::views::{Organization, Project, Silo, Vpc}; use omicron_sled_agent::sim::SledAgent; use std::sync::Arc; use uuid::Uuid; @@ -58,6 +58,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 6047a667889..7c525812758 100644 --- a/nexus/tests/integration_tests/basic.rs +++ b/nexus/tests/integration_tests/basic.rs @@ -53,120 +53,120 @@ 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 50ef122c5ef..26272e96fbe 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 939ba538de8..1c3ea8f562d 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 a28407e8002..f4af75cce8b 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 e7f4fa0fe11..c009066df9b 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -81,6 +81,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/openapi/nexus.json b/openapi/nexus.json index a88e86dd099..f7b3304c0d5 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -3440,6 +3440,170 @@ } } }, + "/silos": { + "get": { + "tags": [ + "silos" + ], + "summary": "List all silos (that are discoverable).", + "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": [ @@ -5054,6 +5218,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", diff --git a/tools/oxapi_demo b/tools/oxapi_demo index 94abf753fc9..e5800c9876e 100755 --- a/tools/oxapi_demo +++ b/tools/oxapi_demo @@ -19,6 +19,13 @@ GENERAL OPTIONS (default behavior: use "spoof" authentication for endpoints that require it) +SILOS + + silos_list + silo_create_demo SILO NAME + silo_get SILO_NAME + silo_delete SILO_NAME + ORGANIZATIONS organizations_list @@ -195,6 +202,31 @@ function mkjson # API commands # +function cmd_silos_list +{ + [[ $# != 0 ]] && usage "expected no arguments" + do_curl_authn /silos +} + +function cmd_silo_create_demo +{ + [[ $# != 1 ]] && usage "expected SILO_NAME" + mkjson name="${1}" description="a silo called ${1}" discoverable="true" | + do_curl_authn /silos -X POST -T - +} + +function cmd_silo_get +{ + [[ $# != 1 ]] && usage "expected SILO_NAME" + do_curl_authn "/silos/${1}" +} + +function cmd_silo_delete +{ + [[ $# != 1 ]] && usage "expected SILO_NAME" + do_curl_authn "/silos/${1}" -X DELETE +} + function cmd_organizations_list { [[ $# != 0 ]] && usage "expected no arguments"