diff --git a/common/src/api/external/error.rs b/common/src/api/external/error.rs index 04e7f9dae3..15a56b6c63 100644 --- a/common/src/api/external/error.rs +++ b/common/src/api/external/error.rs @@ -93,6 +93,12 @@ impl From<&Name> for LookupType { } } +impl From for LookupType { + fn from(uuid: Uuid) -> Self { + LookupType::ById(uuid) + } +} + impl Error { /// Returns whether the error is likely transient and could reasonably be /// retried diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index a8dc64ba16..29c9824cf5 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -516,6 +516,7 @@ pub enum ResourceType { Fleet, Silo, SiloUser, + SshKey, ConsoleSession, GlobalImage, Organization, diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 3bd2683542..3c5cbc31c5 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -216,6 +216,33 @@ CREATE TABLE omicron.public.silo_user ( time_deleted TIMESTAMPTZ ); +/* + * Users' public SSH keys, per RFD 44 + */ +CREATE TABLE omicron.public.ssh_key ( + id UUID PRIMARY KEY, + name STRING(63) NOT NULL, + description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + + /* FK into silo_user table */ + silo_user_id UUID NOT NULL, + + /* + * A 4096 bit RSA key without comment encodes to 726 ASCII characters. + * A (256 bit) Ed25519 key w/o comment encodes to 82 ASCII characters. + */ + public_key STRING(1023) NOT NULL +); + +CREATE UNIQUE INDEX ON omicron.public.ssh_key ( + silo_user_id, + name +) WHERE + time_deleted IS NULL; + /* * Organizations */ diff --git a/nexus/src/authz/api_resources.rs b/nexus/src/authz/api_resources.rs index c9017ec3c5..41dad42d91 100644 --- a/nexus/src/authz/api_resources.rs +++ b/nexus/src/authz/api_resources.rs @@ -395,14 +395,6 @@ authz_resource! { polar_snippet = FleetChild, } -authz_resource! { - name = "SiloUser", - parent = "Fleet", - primary_key = Uuid, - roles_allowed = false, - polar_snippet = FleetChild, -} - authz_resource! { name = "RoleBuiltin", parent = "Fleet", @@ -435,6 +427,22 @@ authz_resource! { polar_snippet = Custom, } +authz_resource! { + name = "SiloUser", + parent = "Silo", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = Custom, +} + +authz_resource! { + name = "SshKey", + parent = "SiloUser", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = Custom, +} + authz_resource! { name = "Sled", parent = "Fleet", diff --git a/nexus/src/authz/omicron.polar b/nexus/src/authz/omicron.polar index ed3312d6ba..b5ee29a9cb 100644 --- a/nexus/src/authz/omicron.polar +++ b/nexus/src/authz/omicron.polar @@ -247,8 +247,35 @@ has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList) # read silo users and modify their sessions. This is necessary for login to # work. has_permission(actor: AuthenticatedActor, "read", user: SiloUser) - if has_role(actor, "external-authenticator", user.fleet); + if has_role(actor, "external-authenticator", user.silo.fleet); has_permission(actor: AuthenticatedActor, "read", session: ConsoleSession) if has_role(actor, "external-authenticator", session.fleet); has_permission(actor: AuthenticatedActor, "modify", session: ConsoleSession) if has_role(actor, "external-authenticator", session.fleet); + +resource SiloUser { + permissions = [ + "list_children", + "modify", + "read", + "create_child", + ]; + relations = { parent_silo: Silo }; + + "list_children" if "viewer" on "parent_silo"; + "read" if "viewer" on "parent_silo"; + "modify" if "admin" on "parent_silo"; + "create_child" if "admin" on "parent_silo"; +} +has_relation(silo: Silo, "parent_silo", user: SiloUser) + if user.silo = silo; + +resource SshKey { + permissions = [ "read", "modify" ]; + relations = { silo_user: SiloUser }; + + "read" if "read" on "silo_user"; + "modify" if "modify" on "silo_user"; +} +has_relation(user: SiloUser, "silo_user", ssh_key: SshKey) + if ssh_key.silo_user = user; diff --git a/nexus/src/authz/oso_generic.rs b/nexus/src/authz/oso_generic.rs index 7669786f84..185cecd355 100644 --- a/nexus/src/authz/oso_generic.rs +++ b/nexus/src/authz/oso_generic.rs @@ -66,6 +66,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { ConsoleSession::init(), Rack::init(), RoleBuiltin::init(), + SshKey::init(), Silo::init(), SiloUser::init(), Sled::init(), diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index dbdd2114b3..83126f60e7 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -42,9 +42,10 @@ use crate::db::{ InstanceRuntimeState, Name, NetworkInterface, Organization, OrganizationUpdate, OximeterInfo, ProducerEndpoint, Project, ProjectUpdate, Region, RoleAssignmentBuiltin, RoleBuiltin, RouterRoute, - RouterRouteUpdate, Silo, SiloUser, Sled, UpdateAvailableArtifact, - UserBuiltin, Volume, Vpc, VpcFirewallRule, VpcRouter, VpcRouterUpdate, - VpcSubnet, VpcSubnetUpdate, VpcUpdate, Zpool, + RouterRouteUpdate, Silo, SiloUser, Sled, SshKey, + UpdateAvailableArtifact, UserBuiltin, Volume, Vpc, VpcFirewallRule, + VpcRouter, VpcRouterUpdate, VpcSubnet, VpcSubnetUpdate, VpcUpdate, + Zpool, }, pagination::paginated, pagination::paginated_multicolumn, @@ -2769,6 +2770,75 @@ impl DataStore { ) }) } + + // SSH public keys + + pub async fn ssh_keys_list( + &self, + opctx: &OpContext, + authz_user: &authz::SiloUser, + page_params: &DataPageParams<'_, Name>, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, authz_user).await?; + + use db::schema::ssh_key::dsl; + paginated(dsl::ssh_key, dsl::name, page_params) + .filter(dsl::silo_user_id.eq(authz_user.id())) + .filter(dsl::time_deleted.is_null()) + .select(SshKey::as_select()) + .load_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } + + /// Create a new SSH public key for a user. + pub async fn ssh_key_create( + &self, + opctx: &OpContext, + authz_user: &authz::SiloUser, + ssh_key: SshKey, + ) -> CreateResult { + assert_eq!(authz_user.id(), ssh_key.silo_user_id); + opctx.authorize(authz::Action::CreateChild, authz_user).await?; + + use db::schema::ssh_key::dsl; + diesel::insert_into(dsl::ssh_key) + .values(ssh_key) + .returning(SshKey::as_returning()) + .get_result_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| { + Error::internal_error(&format!( + "error creating SSH key: {:?}", + e + )) + }) + } + + /// Delete an existing SSH public key. + pub async fn ssh_key_delete( + &self, + opctx: &OpContext, + authz_ssh_key: &authz::SshKey, + ) -> DeleteResult { + opctx.authorize(authz::Action::Delete, authz_ssh_key).await?; + + use db::schema::ssh_key::dsl; + diesel::update(dsl::ssh_key) + .filter(dsl::id.eq(authz_ssh_key.id())) + .filter(dsl::time_deleted.is_null()) + .set(dsl::time_deleted.eq(Utc::now())) + .check_if_exists::(authz_ssh_key.id()) + .execute_and_check(self.pool_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByResource(authz_ssh_key), + ) + })?; + Ok(()) + } } /// Constructs a DataStore for use in test suites that has preloaded the @@ -2890,10 +2960,7 @@ mod test { // Associate silo with user let silo_user = datastore - .silo_user_create(SiloUser::new( - Uuid::new_v4(), /* silo id */ - silo_user_id, - )) + .silo_user_create(SiloUser::new(*SILO_ID, silo_user_id)) .await .unwrap(); @@ -3376,4 +3443,75 @@ mod test { let _ = db.cleanup().await; logctx.cleanup_successful(); } + + #[tokio::test] + async fn test_ssh_keys() { + let logctx = dev::test_setup_log("test_ssh_keys"); + let mut db = test_setup_database(&logctx.log).await; + let (opctx, datastore) = datastore_test(&logctx, &db).await; + + // Create a new Silo user so that we can lookup their keys. + let silo_user_id = Uuid::new_v4(); + let silo_user = datastore + .silo_user_create(SiloUser::new(*SILO_ID, silo_user_id)) + .await + .unwrap(); + assert_eq!(silo_user.id(), silo_user_id); + + let (.., authz_user) = LookupPath::new(&opctx, &datastore) + .silo_user_id(silo_user_id) + .lookup_for(authz::Action::CreateChild) + .await + .unwrap(); + assert_eq!(authz_user.id(), silo_user_id); + + // Create a new SSH public key for the new user. + let key_name = Name::try_from(String::from("sshkey")).unwrap(); + let public_key = "ssh-test AAAAAAAAKEY".to_string(); + let ssh_key = SshKey::new( + silo_user_id, + params::SshKeyCreate { + identity: IdentityMetadataCreateParams { + name: key_name.clone(), + description: "my SSH public key".to_string(), + }, + public_key, + }, + ); + let created = datastore + .ssh_key_create(&opctx, &authz_user, ssh_key.clone()) + .await + .unwrap(); + assert_eq!(created.silo_user_id, ssh_key.silo_user_id); + assert_eq!(created.public_key, ssh_key.public_key); + + // Lookup the key we just created. + let (authz_silo, authz_silo_user, authz_ssh_key, found) = + LookupPath::new(&opctx, &datastore) + .silo_user_id(silo_user_id) + .ssh_key_name(&key_name.into()) + .fetch() + .await + .unwrap(); + assert_eq!(authz_silo.id(), *SILO_ID); + assert_eq!(authz_silo_user.id(), silo_user_id); + assert_eq!(found.silo_user_id, ssh_key.silo_user_id); + assert_eq!(found.public_key, ssh_key.public_key); + + // Trying to insert the same one again fails. + let duplicate = datastore + .ssh_key_create(&opctx, &authz_user, ssh_key.clone()) + .await; + assert!(matches!( + duplicate, + Err(Error::InternalError { internal_message: _ }) + )); + + // Delete the key we just created. + datastore.ssh_key_delete(&opctx, &authz_ssh_key).await.unwrap(); + + // Clean up. + db.cleanup().await.unwrap(); + logctx.cleanup_successful(); + } } diff --git a/nexus/src/db/db-macros/src/lookup.rs b/nexus/src/db/db-macros/src/lookup.rs index af1b05743c..c9f972139a 100644 --- a/nexus/src/db/db-macros/src/lookup.rs +++ b/nexus/src/db/db-macros/src/lookup.rs @@ -340,7 +340,7 @@ fn generate_misc_helpers(config: &Config) -> TokenStream { if let Err(_) = &maybe_silo { error!( log, - "unexpected successful lookup of siloed resource\ + "unexpected successful lookup of siloed resource \ {:?} with no silo in OpContext", #resource_name_str, ); @@ -352,8 +352,8 @@ fn generate_misc_helpers(config: &Config) -> TokenStream { use crate::authz::ApiResourceError; error!( log, - "unexpected successful lookup of siloed resource\ - {:?} in a different Silo from current actor (resource\ + "unexpected successful lookup of siloed resource \ + {:?} in a different Silo from current actor (resource \ Silo {}, actor Silo {})", #resource_name_str, resource_silo_id, diff --git a/nexus/src/db/lookup.rs b/nexus/src/db/lookup.rs index 210e67a8c9..5bbe68b2cf 100644 --- a/nexus/src/db/lookup.rs +++ b/nexus/src/db/lookup.rs @@ -304,7 +304,7 @@ impl<'a> LookupPath<'a> { Silo { key: SiloKey::PrimaryKey(Root { lookup_root: self }, id) } } - /// Select a resource of type Silo, identified by its id + /// Select a resource of type Silo, identified by its name pub fn silo_name<'b, 'c>(self, name: &'b Name) -> Silo<'c> where 'a: 'c, @@ -403,6 +403,24 @@ lookup_resource! { primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] } +lookup_resource! { + name = "SiloUser", + ancestors = [ "Silo" ], + children = [ "SshKey" ], + lookup_by_name = false, + soft_deletes = true, + primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] +} + +lookup_resource! { + name = "SshKey", + ancestors = [ "Silo", "SiloUser" ], + children = [], + lookup_by_name = true, + soft_deletes = true, + primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] +} + lookup_resource! { name = "Organization", ancestors = [ "Silo" ], @@ -509,15 +527,6 @@ lookup_resource! { ] } -lookup_resource! { - name = "SiloUser", - ancestors = [], - children = [], - lookup_by_name = false, - soft_deletes = true, - primary_key_columns = [ { column_name = "id", rust_type = Uuid } ] -} - lookup_resource! { name = "Sled", ancestors = [], diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 34470714c9..9a8835c8e7 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -11,8 +11,9 @@ use crate::db::schema::{ console_session, dataset, disk, global_image, image, instance, metric_producer, network_interface, organization, oximeter, project, rack, region, 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, + silo_user, sled, snapshot, ssh_key, update_available_artifact, + user_builtin, volume, vpc, vpc_firewall_rule, vpc_router, vpc_subnet, + zpool, }; use crate::defaults; use crate::external_api::params; @@ -1003,6 +1004,35 @@ impl SiloUser { } } +/// Describes a user's public SSH key within the database. +#[derive(Clone, Debug, Insertable, Queryable, Resource, Selectable)] +#[table_name = "ssh_key"] +pub struct SshKey { + #[diesel(embed)] + identity: SshKeyIdentity, + + pub silo_user_id: Uuid, + pub public_key: String, +} + +impl SshKey { + pub fn new(silo_user_id: Uuid, params: params::SshKeyCreate) -> Self { + Self::new_with_id(Uuid::new_v4(), silo_user_id, params) + } + + pub fn new_with_id( + id: Uuid, + silo_user_id: Uuid, + params: params::SshKeyCreate, + ) -> Self { + Self { + identity: SshKeyIdentity::new(id, params.identity), + silo_user_id, + public_key: params.public_key, + } + } +} + /// Describes an organization within the database. #[derive(Queryable, Insertable, Debug, Resource, Selectable)] #[table_name = "organization"] diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 2b3fa000c1..21245dc5da 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -157,6 +157,19 @@ table! { } } +table! { + ssh_key (id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + silo_user_id -> Uuid, + public_key -> Text, + } +} + table! { organization (id) { id -> Uuid, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 369f7c7674..92ee274ad5 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -12,7 +12,7 @@ use super::{ console_api, params, views::{ GlobalImage, Image, Organization, Project, Rack, Role, Silo, Sled, - Snapshot, User, Vpc, VpcRouter, VpcSubnet, + Snapshot, SshKey, User, Vpc, VpcRouter, VpcSubnet, }, }; use crate::context::OpContext; @@ -174,6 +174,11 @@ pub fn external_api() -> NexusApiDescription { api.register(roles_get)?; api.register(roles_get_role)?; + api.register(sshkeys_get)?; + api.register(sshkeys_get_key)?; + api.register(sshkeys_post)?; + api.register(sshkeys_delete_key)?; + api.register(console_api::spoof_login)?; api.register(console_api::spoof_login_form)?; api.register(console_api::login_redirect)?; @@ -2854,6 +2859,113 @@ async fn roles_get_role( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +// Per-user SSH public keys + +/// List the current user's SSH public keys +#[endpoint { + method = GET, + path = "/session/me/sshkeys", + tags = ["sshkeys"], +}] +async fn sshkeys_get( + rqctx: Arc>>, + query_params: Query, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let &actor = opctx.authn.actor_required()?; + let page_params = + data_page_params_for(&rqctx, &query)?.map_name(Name::ref_cast); + let ssh_keys = nexus + .ssh_keys_list(&opctx, actor.id, &page_params) + .await? + .into_iter() + .map(SshKey::from) + .collect::>(); + Ok(HttpResponseOk(ScanByName::results_page(&query, ssh_keys)?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create a new SSH public key for the current user +#[endpoint { + method = POST, + path = "/session/me/sshkeys", + tags = ["sshkeys"], +}] +async fn sshkeys_post( + rqctx: Arc>>, + new_key: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let &actor = opctx.authn.actor_required()?; + let ssh_key = nexus + .ssh_key_create(&opctx, actor.id, new_key.into_inner()) + .await?; + Ok(HttpResponseCreated(ssh_key.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Path parameters for SSH key requests by name +#[derive(Deserialize, JsonSchema)] +struct SshKeyPathParams { + ssh_key_name: Name, +} + +/// Get (by name) an SSH public key belonging to the current user +#[endpoint { + method = GET, + path = "/session/me/sshkeys/{ssh_key_name}", + tags = ["sshkeys"], +}] +async fn sshkeys_get_key( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let ssh_key_name = &path.ssh_key_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let &actor = opctx.authn.actor_required()?; + let ssh_key = + nexus.ssh_key_fetch(&opctx, actor.id, ssh_key_name).await?; + Ok(HttpResponseOk(ssh_key.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete (by name) an SSH public key belonging to the current user +#[endpoint { + method = DELETE, + path = "/session/me/sshkeys/{ssh_key_name}", + tags = ["sshkeys"], +}] +async fn sshkeys_delete_key( + rqctx: Arc>>, + path_params: Path, +) -> Result { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let ssh_key_name = &path.ssh_key_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let &actor = opctx.authn.actor_required()?; + nexus.ssh_key_delete(&opctx, actor.id, ssh_key_name).await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + #[cfg(test)] mod test { use super::external_api; diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index d9c93629b5..5df760fce9 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -452,6 +452,22 @@ pub struct UserBuiltinCreate { pub identity: IdentityMetadataCreateParams, } +// SSH PUBLIC KEYS +// +// The SSH key mangement endpoints are currently under `/session/me`, +// and so have an implicit silo user ID which must be passed seperately +// to the creation routine. Note that this disagrees with RFD 44. + +/// Create-time parameters for an [`SshKey`](crate::external_api::views::SshKey) +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SshKeyCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// SSH public key, e.g., `"ssh-ed25519 AAAAC3NzaC..."` + pub public_key: String, +} + #[cfg(test)] mod test { use super::*; diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index dab68d58d2..5ba81fac1c 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -104,6 +104,12 @@ "url": "http://oxide.computer/docs/#xxx" } }, + "sshkeys": { + "description": "Public SSH keys for an individual user", + "external_docs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, "subnets": { "description": "This tag should be moved into a generic network tag", "external_docs": { diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 5891974b94..0da41959b4 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -317,3 +317,28 @@ impl From for Role { } } } + +// SSH KEYS + +/// Client view of a [`SshKey`] +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SshKey { + #[serde(flatten)] + pub identity: IdentityMetadata, + + /// The user to whom this key belongs + pub silo_user_id: Uuid, + + /// SSH public key, e.g., `"ssh-ed25519 AAAAC3NzaC..."` + pub public_key: String, +} + +impl From for SshKey { + fn from(ssh_key: model::SshKey) -> Self { + Self { + identity: ssh_key.identity(), + silo_user_id: ssh_key.silo_user_id, + public_key: ssh_key.public_key, + } + } +} diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 365d3c08e3..b6c4dfb3e3 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -15,6 +15,7 @@ use crate::db::model::DatasetKind; use crate::db::model::Name; use crate::db::model::RouterRoute; use crate::db::model::SiloUser; +use crate::db::model::SshKey; use crate::db::model::UpdateArtifactKind; use crate::db::model::VpcRouter; use crate::db::model::VpcRouterKind; @@ -3818,13 +3819,78 @@ impl Nexus { &self, opctx: &OpContext, silo_user_id: Uuid, - ) -> LookupResult { - let (.., db_silo_user) = LookupPath::new(opctx, &self.db_datastore) + ) -> LookupResult { + let (.., db_silo_user) = LookupPath::new(opctx, &self.datastore()) .silo_user_id(silo_user_id) .fetch() .await?; Ok(db_silo_user) } + + // SSH public keys + + pub async fn ssh_keys_list( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + page_params: &DataPageParams<'_, Name>, + ) -> ListResultVec { + let (.., authz_user) = LookupPath::new(opctx, &self.datastore()) + .silo_user_id(silo_user_id) + .lookup_for(authz::Action::ListChildren) + .await?; + assert_eq!(authz_user.id(), silo_user_id); + self.db_datastore.ssh_keys_list(opctx, &authz_user, page_params).await + } + + pub async fn ssh_key_fetch( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + ssh_key_name: &Name, + ) -> LookupResult { + let (.., ssh_key) = LookupPath::new(opctx, &self.datastore()) + .silo_user_id(silo_user_id) + .ssh_key_name(ssh_key_name) + .fetch() + .await?; + assert_eq!(ssh_key.name(), ssh_key_name); + Ok(ssh_key) + } + + pub async fn ssh_key_create( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + params: params::SshKeyCreate, + ) -> CreateResult { + let ssh_key = db::model::SshKey::new(silo_user_id, params); + let (.., authz_user) = LookupPath::new(opctx, &self.datastore()) + .silo_user_id(silo_user_id) + .lookup_for(authz::Action::CreateChild) + .await?; + assert_eq!(authz_user.id(), silo_user_id); + Ok(self + .db_datastore + .ssh_key_create(opctx, &authz_user, ssh_key) + .await?) + } + + pub async fn ssh_key_delete( + &self, + opctx: &OpContext, + silo_user_id: Uuid, + ssh_key_name: &Name, + ) -> DeleteResult { + let (.., authz_user, authz_ssh_key) = + LookupPath::new(opctx, &self.datastore()) + .silo_user_id(silo_user_id) + .ssh_key_name(ssh_key_name) + .lookup_for(authz::Action::Delete) + .await?; + assert_eq!(authz_user.id(), silo_user_id); + self.db_datastore.ssh_key_delete(opctx, &authz_ssh_key).await + } } /// For unimplemented endpoints, indicates whether the resource identified diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 0697b343a3..4a264e2b2e 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -17,6 +17,7 @@ mod projects; mod roles_builtin; mod router_routes; mod silos; +mod ssh_keys; mod subnet_allocation; mod timeseries; mod unauthorized; diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index ab74ffc923..b704b6d0c6 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -66,7 +66,7 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { 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 + // Create a new user in the discoverable silo let new_silo_user = nexus .silo_user_create( silos[0].identity.id, /* silo id */ @@ -75,9 +75,10 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { .await .unwrap(); + // TODO-coverage, TODO-security: Add test for Silo-local session + // when we can use users in another Silo. + let authn_opctx = nexus.opctx_external_authn(); - let session = - nexus.session_create(authn_opctx, new_silo_user.id()).await.unwrap(); // Create organization with built-in user auth // Note: this currently goes to the built-in silo! @@ -136,10 +137,4 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { .silo_user_fetch(authn_opctx, new_silo_user.id()) .await .expect_err("unexpected success"); - - // Verify new user's console session isn't valid anymore. - nexus - .session_fetch(authn_opctx, session.token.clone()) - .await - .expect_err("unexpected success"); } diff --git a/nexus/tests/integration_tests/ssh_keys.rs b/nexus/tests/integration_tests/ssh_keys.rs new file mode 100644 index 0000000000..05ce6e1ec8 --- /dev/null +++ b/nexus/tests/integration_tests/ssh_keys.rs @@ -0,0 +1,122 @@ +//! Sanity-tests for public SSH keys + +use http::{method::Method, StatusCode}; + +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; +use nexus_test_utils::resource_helpers::objects_list_page_authz; +use nexus_test_utils::ControlPlaneTestContext; +use nexus_test_utils_macros::nexus_test; + +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_nexus::external_api::params::SshKeyCreate; +use omicron_nexus::external_api::views::SshKey; + +#[nexus_test] +async fn test_ssh_keys(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Ensure we start with an empty list of SSH keys. + let keys = objects_list_page_authz::(client, "/session/me/sshkeys") + .await + .items; + assert_eq!(keys.len(), 0); + + // Ensure GET fails on non-existent keys. + NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::GET, + "/session/me/sshkeys/nonexistent", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make GET request"); + + // Ensure we can POST new keys. + let new_keys = vec![ + ("key1", "an SSH public key", "ssh-test AAAAAAAA"), + ("key2", "another SSH public key", "ssh-test BBBBBBBB"), + ("key3", "yet another public key", "ssh-test CCCCCCCC"), + ]; + for (name, description, public_key) in &new_keys { + let new_key: SshKey = NexusRequest::objects_post( + client, + "/session/me/sshkeys", + &SshKeyCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: description.to_string(), + }, + public_key: public_key.to_string(), + }, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make POST request") + .parsed_body() + .unwrap(); + assert_eq!(new_key.identity.name.as_str(), *name); + assert_eq!(new_key.identity.description, *description); + assert_eq!(new_key.public_key, *public_key); + } + + // Ensure we can GET one of the keys we just posted. + let key1: SshKey = NexusRequest::object_get( + client, + &format!("/session/me/sshkeys/{}", new_keys[0].0), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make GET request") + .parsed_body() + .unwrap(); + assert_eq!(key1.identity.name.as_str(), new_keys[0].0); + assert_eq!(key1.identity.description, new_keys[0].1); + assert_eq!(key1.public_key, new_keys[0].2); + + // Ensure we can GET the list of keys we just posted. + // TODO-coverage: pagination + let keys: Vec = NexusRequest::iter_collection_authn( + client, + "/session/me/sshkeys", + "sort_by=name-ascending", + Some(new_keys.len()), + ) + .await + .expect("failed to list keys") + .all_items; + assert_eq!(keys.len(), new_keys.len()); + for (key, (name, description, public_key)) in + keys.iter().zip(new_keys.iter()) + { + assert_eq!(key.identity.name.as_str(), *name); + assert_eq!(key.identity.description, *description); + assert_eq!(key.public_key, *public_key); + } + + // Ensure we can DELETE a key. + let deleted_key_name = new_keys[0].0; + NexusRequest::object_delete( + client, + &format!("/session/me/sshkeys/{}", deleted_key_name), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to DELETE key"); + + // Ensure that we can't GET the key we just deleted. + NexusRequest::expect_failure( + client, + StatusCode::NOT_FOUND, + Method::GET, + &format!("/session/me/sshkeys/{}", deleted_key_name), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make GET request"); +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 80da08f1c5..b1eca1fa74 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -192,7 +192,7 @@ lazy_static! { SetupReq { url: "/images", body: serde_json::to_value(&*DEMO_IMAGE_CREATE).unwrap(), - } + }, ]; } @@ -522,11 +522,14 @@ fn record_operation(whichtest: WhichTest<'_>) { // Note that this likely still writes the color-changing control // characters to the real stdout, even without "--nocapture". That // sucks, but at least you don't see them. - term.fg(term::color::GREEN).unwrap(); - term.flush().unwrap(); + // + // We also don't unwrap() the results of printing control codes + // in case the terminal doesn't support them. + let _ = term.fg(term::color::GREEN); + let _ = term.flush(); print!("{}", c); - term.reset().unwrap(); - term.flush().unwrap(); + let _ = term.reset(); + let _ = term.flush(); } else { print!("{}", c); } diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 8ef1eaee3f..f6fc57bdfb 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -118,6 +118,13 @@ project_snapshots_get /organizations/{organization_name}/proj project_snapshots_get_snapshot /organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name} project_snapshots_post /organizations/{organization_name}/projects/{project_name}/snapshots +API operations found with tag "sshkeys" +OPERATION ID URL PATH +sshkeys_delete_key /session/me/sshkeys/{ssh_key_name} +sshkeys_get /session/me/sshkeys +sshkeys_get_key /session/me/sshkeys/{ssh_key_name} +sshkeys_post /session/me/sshkeys + API operations found with tag "subnets" OPERATION ID URL PATH subnet_network_interfaces_get /organizations/{organization_name}/projects/{project_name}/vpcs/{vpc_name}/subnets/{subnet_name}/network-interfaces diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 48197f499b..89a0dcc71e 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,4 +1,8 @@ API endpoints with no coverage in authz tests: +sshkeys_delete_key (delete "/session/me/sshkeys/{ssh_key_name}") session_me (get "/session/me") +sshkeys_get (get "/session/me/sshkeys") +sshkeys_get_key (get "/session/me/sshkeys/{ssh_key_name}") spoof_login (post "/login") logout (post "/logout") +sshkeys_post (post "/session/me/sshkeys") diff --git a/openapi/nexus.json b/openapi/nexus.json index c5a0966fd1..409ebadc1a 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -4400,6 +4400,168 @@ } } }, + "/session/me/sshkeys": { + "get": { + "tags": [ + "sshkeys" + ], + "summary": "List the current user's SSH public keys", + "operationId": "sshkeys_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/NameSortMode" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SshKeyResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + }, + "post": { + "tags": [ + "sshkeys" + ], + "summary": "Create a new SSH public key for the current user", + "operationId": "sshkeys_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SshKeyCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SshKey" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/session/me/sshkeys/{ssh_key_name}": { + "get": { + "tags": [ + "sshkeys" + ], + "summary": "Get (by name) an SSH public key belonging to the current user", + "operationId": "sshkeys_get_key", + "parameters": [ + { + "in": "path", + "name": "ssh_key_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SshKey" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "sshkeys" + ], + "summary": "Delete (by name) an SSH public key belonging to the current user", + "operationId": "sshkeys_delete_key", + "parameters": [ + { + "in": "path", + "name": "ssh_key_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" + } + } + } + }, "/silos": { "get": { "tags": [ @@ -7062,6 +7224,99 @@ "items" ] }, + "SshKey": { + "description": "Client view of a [`SshKey`]", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "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" + } + ] + }, + "public_key": { + "description": "SSH public key, e.g., `\"ssh-ed25519 AAAAC3NzaC...\"`", + "type": "string" + }, + "silo_user_id": { + "description": "The user to whom this key belongs", + "type": "string", + "format": "uuid" + }, + "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", + "id", + "name", + "public_key", + "silo_user_id", + "time_created", + "time_modified" + ] + }, + "SshKeyCreate": { + "description": "Create-time parameters for an [`SshKey`](crate::external_api::views::SshKey)", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "public_key": { + "description": "SSH public key, e.g., `\"ssh-ed25519 AAAAC3NzaC...\"`", + "type": "string" + } + }, + "required": [ + "description", + "name", + "public_key" + ] + }, + "SshKeyResultsPage": { + "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/SshKey" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "TimeseriesName": { "title": "The name of a timeseries", "description": "Names are constructed by concatenating the target and metric names with ':'. Target and metric names must be lowercase alphanumeric characters with '_' separating words.", @@ -8196,6 +8451,13 @@ "url": "http://oxide.computer/docs/#xxx" } }, + { + "name": "sshkeys", + "description": "Public SSH keys for an individual user", + "externalDocs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, { "name": "subnets", "description": "This tag should be moved into a generic network tag",