diff --git a/Cargo.lock b/Cargo.lock index 062824c006a..0a116ad21ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6600,6 +6600,7 @@ dependencies = [ "futures", "gateway-client", "gateway-types", + "hex", "hyper-rustls", "id-map", "iddqd", @@ -6646,6 +6647,7 @@ dependencies = [ "regex", "rustls 0.22.4", "schemars", + "scim2-rs", "semver 1.0.27", "serde", "serde_json", diff --git a/nexus-config/src/nexus_config.rs b/nexus-config/src/nexus_config.rs index be98e15f3b3..588374407b0 100644 --- a/nexus-config/src/nexus_config.rs +++ b/nexus-config/src/nexus_config.rs @@ -922,6 +922,7 @@ pub enum SchemeName { Spoof, SessionCookie, AccessToken, + ScimToken, } impl std::str::FromStr for SchemeName { @@ -932,6 +933,7 @@ impl std::str::FromStr for SchemeName { "spoof" => Ok(SchemeName::Spoof), "session_cookie" => Ok(SchemeName::SessionCookie), "access_token" => Ok(SchemeName::AccessToken), + "scim_token" => Ok(SchemeName::ScimToken), _ => Err(anyhow!("unsupported authn scheme: {:?}", s)), } } @@ -943,6 +945,7 @@ impl std::fmt::Display for SchemeName { SchemeName::Spoof => "spoof", SchemeName::SessionCookie => "session_cookie", SchemeName::AccessToken => "access_token", + SchemeName::ScimToken => "scim", }) } } diff --git a/nexus/auth/src/authn/external/mod.rs b/nexus/auth/src/authn/external/mod.rs index f420b690673..e951819830e 100644 --- a/nexus/auth/src/authn/external/mod.rs +++ b/nexus/auth/src/authn/external/mod.rs @@ -15,6 +15,7 @@ use slog::trace; use std::borrow::Borrow; use uuid::Uuid; +pub mod scim; pub mod session_cookie; pub mod spoof; pub mod token; diff --git a/nexus/auth/src/authn/external/scim.rs b/nexus/auth/src/authn/external/scim.rs new file mode 100644 index 00000000000..d8b502f17ba --- /dev/null +++ b/nexus/auth/src/authn/external/scim.rs @@ -0,0 +1,99 @@ +// 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/. + +//! SCIM-only bearer tokens + +use super::super::Details; +use super::HttpAuthnScheme; +use super::Reason; +use super::SchemeResult; +use crate::authn; +use async_trait::async_trait; +use headers::HeaderMapExt; +use headers::authorization::{Authorization, Bearer}; + +// This scheme is intended only for SCIM provisioning clients. +// +// For ease of integration into existing clients, we use RFC 6750 bearer tokens. +// This mechanism in turn uses HTTP's "Authorization" header. In practice, it +// looks like this: +// +// Authorization: Bearer oxide-scim-01c90c58085fed6a230d137b9b9b5e7501d0a523 +// ^^^^^^^^^^^^^ ^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// | | | | +// | | | +--- specifies the token itself +// | | +--------------- specifies this "token" mechanism +// | +---------------------- specifies RFC 6750 bearer tokens +// +------------------------------------- standard HTTP authentication hdr +// +// (That's not a typo -- the "authorization" header is generally used to specify +// _authentication_ information. Similarly, the "Unauthorized" HTTP response +// code usually describes an _authentication_ error.) + +pub const SCIM_TOKEN_SCHEME_NAME: authn::SchemeName = + authn::SchemeName("scim_token"); + +/// Prefix used on the bearer token to identify this scheme +// RFC 6750 expects bearer tokens to be opaque base64-encoded data. In our case, +// the data we want to represent (this prefix, plus valid tokens) are subsets of +// the base64 character set, so we do not bother encoding them. +const TOKEN_PREFIX: &str = "oxide-scim-"; + +/// Implements a SCIM provisioning client specific bearer-token-based +/// authentication scheme. +#[derive(Debug)] +pub struct HttpAuthnScimToken; + +#[async_trait] +impl HttpAuthnScheme for HttpAuthnScimToken +where + T: ScimTokenContext + Send + Sync + 'static, +{ + fn name(&self) -> authn::SchemeName { + SCIM_TOKEN_SCHEME_NAME + } + + async fn authn( + &self, + ctx: &T, + _log: &slog::Logger, + request: &dropshot::RequestInfo, + ) -> SchemeResult { + let headers = request.headers(); + match parse_token(headers.typed_get().as_ref()) { + Err(error) => SchemeResult::Failed(error), + Ok(None) => SchemeResult::NotRequested, + Ok(Some(token)) => match ctx.scim_token_actor(token).await { + Err(error) => SchemeResult::Failed(error), + Ok(actor) => SchemeResult::Authenticated(Details { actor }), + }, + } + } +} + +fn parse_token( + raw_value: Option<&Authorization>, +) -> Result, Reason> { + let token = match raw_value { + None => return Ok(None), + Some(bearer) => bearer.token(), + }; + + if !token.starts_with(TOKEN_PREFIX) { + // This is some other kind of bearer token. Maybe another scheme knows + // how to deal with it. + return Ok(None); + } + + Ok(Some(token[TOKEN_PREFIX.len()..].to_string())) +} + +/// A context that can look up a Actor::Scim from a token. +#[async_trait] +pub trait ScimTokenContext { + async fn scim_token_actor( + &self, + token: String, + ) -> Result; +} diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index c1469df0f7d..ff955fb1619 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -32,7 +32,6 @@ pub use nexus_db_fixed_data::silo_user::USER_TEST_PRIVILEGED; pub use nexus_db_fixed_data::silo_user::USER_TEST_UNPRIVILEGED; pub use nexus_db_fixed_data::user_builtin::USER_DB_INIT; pub use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_AUTHN; -pub use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_SCIM; pub use nexus_db_fixed_data::user_builtin::USER_INTERNAL_API; pub use nexus_db_fixed_data::user_builtin::USER_INTERNAL_READ; pub use nexus_db_fixed_data::user_builtin::USER_SAGA_RECOVERY; @@ -46,6 +45,7 @@ use nexus_types::external_api::shared::SiloRole; use nexus_types::identity::Asset; use omicron_common::api::external::LookupType; use omicron_uuid_kinds::BuiltInUserUuid; +use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::SiloUserUuid; use serde::Deserialize; use serde::Serialize; @@ -126,6 +126,8 @@ impl Context { /// Built-in users have no Silo, and so they usually can't do anything that /// might use a Silo. You usually want to use [`Context::silo_required()`] /// if you don't expect to be looking at a built-in user. + /// + /// Additionally, non-user Actors may also be associated with a Silo. pub fn silo_or_builtin( &self, ) -> Result, omicron_common::api::external::Error> { @@ -136,6 +138,11 @@ impl Context { LookupType::ById(*silo_id), )), Actor::UserBuiltin { .. } => None, + Actor::Scim { silo_id } => Some(authz::Silo::new( + authz::FLEET, + *silo_id, + LookupType::ById(*silo_id), + )), }) } @@ -200,12 +207,6 @@ impl Context { Context::context_for_builtin_user(USER_SERVICE_BALANCER.id) } - /// Returns an authenticated context for use for authenticating SCIM - /// requests - pub fn external_scim() -> Context { - Context::context_for_builtin_user(USER_EXTERNAL_SCIM.id) - } - fn context_for_builtin_user(user_builtin_id: BuiltInUserUuid) -> Context { Context { kind: Kind::Authenticated( @@ -307,7 +308,6 @@ mod test { use super::USER_TEST_PRIVILEGED; use super::USER_TEST_UNPRIVILEGED; use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_AUTHN; - use nexus_db_fixed_data::user_builtin::USER_EXTERNAL_SCIM; use nexus_types::identity::Asset; #[test] @@ -350,10 +350,6 @@ mod test { let authn = Context::internal_api(); let actor = authn.actor().unwrap(); assert_eq!(actor.built_in_user_id(), Some(USER_INTERNAL_API.id)); - - let authn = Context::external_scim(); - let actor = authn.actor().unwrap(); - assert_eq!(actor.built_in_user_id(), Some(USER_EXTERNAL_SCIM.id)); } } @@ -382,6 +378,7 @@ pub struct Details { pub enum Actor { UserBuiltin { user_builtin_id: BuiltInUserUuid }, SiloUser { silo_user_id: SiloUserUuid, silo_id: Uuid }, + Scim { silo_id: Uuid }, } impl Actor { @@ -389,6 +386,7 @@ impl Actor { match self { Actor::UserBuiltin { .. } => None, Actor::SiloUser { silo_id, .. } => Some(*silo_id), + Actor::Scim { .. } => None, // XXX scim actor does have a silo id? } } @@ -396,6 +394,7 @@ impl Actor { match self { Actor::UserBuiltin { .. } => None, Actor::SiloUser { silo_user_id, .. } => Some(*silo_user_id), + Actor::Scim { .. } => None, } } @@ -403,17 +402,28 @@ impl Actor { match self { Actor::UserBuiltin { user_builtin_id } => Some(*user_builtin_id), Actor::SiloUser { .. } => None, + Actor::Scim { .. } => None, } } -} -impl From<&Actor> for nexus_db_model::IdentityType { - fn from(actor: &Actor) -> nexus_db_model::IdentityType { - match actor { - Actor::UserBuiltin { .. } => { - nexus_db_model::IdentityType::UserBuiltin - } - Actor::SiloUser { .. } => nexus_db_model::IdentityType::SiloUser, + /// Return a generic UUID and db-model IdentityType for use with looking up + /// role assignments, or None if a role assignment for this type of Actor is + /// invalid. + pub fn id_and_type_for_role_assignment( + &self, + ) -> Option<(Uuid, nexus_db_model::IdentityType)> { + match &self { + Actor::UserBuiltin { user_builtin_id } => Some(( + user_builtin_id.into_untyped_uuid(), + nexus_db_model::IdentityType::UserBuiltin, + )), + Actor::SiloUser { silo_user_id, .. } => Some(( + silo_user_id.into_untyped_uuid(), + nexus_db_model::IdentityType::SiloUser, + )), + // a role assignment for this Actor is invalid, they have a fixed + // policy. + Actor::Scim { .. } => None, } } } @@ -437,6 +447,10 @@ impl std::fmt::Debug for Actor { .field("silo_user_id", &silo_user_id) .field("silo_id", &silo_id) .finish_non_exhaustive(), + Actor::Scim { silo_id } => f + .debug_struct("Actor::Scim") + .field("silo_id", &silo_id) + .finish_non_exhaustive(), } } } diff --git a/nexus/auth/src/authz/actor.rs b/nexus/auth/src/authz/actor.rs index 26f7458b3b8..f0f409de090 100644 --- a/nexus/auth/src/authz/actor.rs +++ b/nexus/auth/src/authz/actor.rs @@ -122,15 +122,23 @@ impl oso::PolarClass for AuthenticatedActor { }, "USER_INTERNAL_API", ) + .add_attribute_getter("is_user", |a: &AuthenticatedActor| { + match a.actor { + authn::Actor::SiloUser { .. } => true, + + authn::Actor::UserBuiltin { .. } => true, + + authn::Actor::Scim { .. } => false, + } + }) .add_attribute_getter("silo", |a: &AuthenticatedActor| { match a.actor { - authn::Actor::SiloUser { silo_id, .. } => { - Some(super::Silo::new( - super::FLEET, - silo_id, - LookupType::ById(silo_id), - )) - } + authn::Actor::SiloUser { silo_id, .. } + | authn::Actor::Scim { silo_id } => Some(super::Silo::new( + super::FLEET, + silo_id, + LookupType::ById(silo_id), + )), authn::Actor::UserBuiltin { .. } => None, } @@ -149,6 +157,8 @@ impl oso::PolarClass for AuthenticatedActor { } authn::Actor::UserBuiltin { .. } => false, + + authn::Actor::Scim { .. } => false, }, ) } diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 94d0ee32231..415b5507ae6 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1015,6 +1015,58 @@ impl AuthorizedResource for AlertClassList { } } +/// Synthetic resource describing the list of SCIM client bearer tokens +/// associated with a Silo +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScimClientBearerTokenList(Silo); + +impl ScimClientBearerTokenList { + pub fn new(silo: Silo) -> ScimClientBearerTokenList { + ScimClientBearerTokenList(silo) + } + + pub fn silo(&self) -> &Silo { + &self.0 + } +} + +impl oso::PolarClass for ScimClientBearerTokenList { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_attribute_getter("silo", |list: &ScimClientBearerTokenList| { + list.0.clone() + }) + } +} + +impl AuthorizedResource for ScimClientBearerTokenList { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // There are no roles on this resource, but we still need to load the + // Silo-related roles. + self.silo().load_roles(opctx, authn, roleset) + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} + // Main resource hierarchy: Projects and their resources authz_resource! { @@ -1429,3 +1481,11 @@ authz_resource! { roles_allowed = false, polar_snippet = Custom, } + +authz_resource! { + name = "ScimClientBearerToken", + parent = "Silo", + primary_key = Uuid, + roles_allowed = false, + polar_snippet = Custom, +} diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index 2aa0284c1be..b1384587b16 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -112,6 +112,8 @@ resource Fleet { } # For fleets specifically, roles can be conferred by roles on the user's Silo. +# Note that certain Actors may not ever have any roles assigned to them, like +# SCIM Actors. has_role(actor: AuthenticatedActor, role: String, _: Fleet) if silo_role in actor.confers_fleet_role(role) and has_role(actor, silo_role, actor.silo.unwrap()); @@ -173,7 +175,7 @@ has_relation(fleet: Fleet, "parent_fleet", silo: Silo) # # It's unclear what else would break if users couldn't see their own Silo. has_permission(actor: AuthenticatedActor, "read", silo: Silo) - if silo in actor.silo; + if actor.is_user and silo in actor.silo; resource Project { permissions = [ @@ -255,7 +257,7 @@ has_permission(actor: AuthenticatedActor, _perm: String, silo_user: SiloUser) if actor.equals_silo_user(silo_user); has_permission(actor: AuthenticatedActor, "read", silo_user: SiloUser) - if silo_user.silo in actor.silo; + if actor.is_user and silo_user.silo in actor.silo; resource SiloGroup { permissions = [ @@ -336,6 +338,27 @@ has_relation(silo: Silo, "parent_silo", saml_identity_provider: SamlIdentityProv has_relation(fleet: Fleet, "parent_fleet", collection: SamlIdentityProvider) if collection.silo.fleet = fleet; +resource ScimClientBearerToken { + permissions = [ "read", "modify" ]; + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # necessary to authenticate SCIM actors + "read" if "external-authenticator" on "parent_fleet"; + + # Silo-level roles grant privileges for SCIM client tokens. + "read" if "admin" on "parent_silo"; + "modify" if "admin" on "parent_silo"; + + # Fleet-level roles also grant privileges for SCIM client tokens. + "read" if "admin" on "parent_fleet"; + "modify" if "admin" on "parent_fleet"; +} +has_relation(silo: Silo, "parent_silo", scim_client_bearer_token: ScimClientBearerToken) + if scim_client_bearer_token.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", collection: ScimClientBearerToken) + if collection.silo.fleet = fleet; + + # # SYNTHETIC RESOURCES OUTSIDE THE SILO HIERARCHY # @@ -453,7 +476,7 @@ has_relation(fleet: Fleet, "parent_fleet", ip_pool_list: IpPoolList) # Any authenticated user can create a child of a provided IP Pool. # This is necessary to use the pools when provisioning instances. has_permission(actor: AuthenticatedActor, "create_child", ip_pool: IpPool) - if silo in actor.silo and silo.fleet = ip_pool.fleet; + if actor.is_user and silo in actor.silo and silo.fleet = ip_pool.fleet; # Describes the policy for reading and writing the audit log resource AuditLog { @@ -631,8 +654,10 @@ has_permission(actor: AuthenticatedActor, "modify", session: ConsoleSession) # even Silo) the device auth is associated with. Any user can claim a device # auth request with the right user code (that's how it works) -- it's the user # code and associated logic that prevents unauthorized access here. -has_permission(_actor: AuthenticatedActor, "read", _device_auth: DeviceAuthRequest); -has_permission(_actor: AuthenticatedActor, "modify", _device_auth: DeviceAuthRequest); +has_permission(actor: AuthenticatedActor, "read", _device_auth: DeviceAuthRequest) + if actor.is_user; +has_permission(actor: AuthenticatedActor, "modify", _device_auth: DeviceAuthRequest) + if actor.is_user; has_permission(actor: AuthenticatedActor, "read", device_token: DeviceAccessToken) if has_role(actor, "external-authenticator", device_token.fleet); @@ -703,3 +728,23 @@ resource AlertClassList { has_relation(fleet: Fleet, "parent_fleet", collection: AlertClassList) if collection.fleet = fleet; + +resource ScimClientBearerTokenList { + permissions = [ "create_child", "list_children" ]; + relations = { parent_silo: Silo, parent_fleet: Fleet }; + + # Silo-level roles grant privileges for SCIM client tokens. + # These are all admin because being able to create these tokens would allow + # a user to grant themselves admin by modifying group membership through SCIM calls + "create_child" if "admin" on "parent_silo"; + "list_children" if "admin" on "parent_silo"; + + # Fleet-level roles also grant privileges for SCIM client tokens, for + # configuration before silo admins are present. + "create_child" if "admin" on "parent_fleet"; + "list_children" if "admin" on "parent_fleet"; +} +has_relation(silo: Silo, "parent_silo", scim_client_bearer_token_list: ScimClientBearerTokenList) + if scim_client_bearer_token_list.silo = silo; +has_relation(fleet: Fleet, "parent_fleet", collection: ScimClientBearerTokenList) + if collection.silo.fleet = fleet; diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 1278b24382c..86e94a224e4 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -121,6 +121,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { UpdateTrustRootList::get_polar_class(), TargetReleaseConfig::get_polar_class(), AlertClassList::get_polar_class(), + ScimClientBearerTokenList::get_polar_class(), ]; for c in classes { oso_builder = oso_builder.register_class(c)?; @@ -175,6 +176,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { Zpool::init(), Service::init(), UserBuiltin::init(), + ScimClientBearerToken::init(), ]; for init in generated_inits { diff --git a/nexus/auth/src/authz/roles.rs b/nexus/auth/src/authz/roles.rs index 114da7e3baf..6c12754670f 100644 --- a/nexus/auth/src/authz/roles.rs +++ b/nexus/auth/src/authz/roles.rs @@ -39,7 +39,6 @@ use crate::authn; use crate::context::OpContext; use omicron_common::api::external::Error; use omicron_common::api::external::ResourceType; -use omicron_uuid_kinds::GenericUuid; use slog::trace; use std::collections::BTreeSet; use uuid::Uuid; @@ -156,19 +155,26 @@ async fn load_directly_attached_roles( "resource_id" => resource_id.to_string(), ); + let Some((identity_id, identity_type)) = + actor.id_and_type_for_role_assignment() + else { + trace!( + opctx.log, + "actor cannot have roles"; + "actor" => ?actor, + "resource_type" => ?resource_type, + "resource_id" => resource_id.to_string(), + ); + // XXX Ok, or an error? + return Ok(()); + }; + let roles = opctx .datastore() .role_asgn_list_for( opctx, - actor.into(), - match &actor { - authn::Actor::SiloUser { silo_user_id, .. } => { - silo_user_id.into_untyped_uuid() - } - authn::Actor::UserBuiltin { user_builtin_id, .. } => { - user_builtin_id.into_untyped_uuid() - } - }, + identity_type, + identity_id, resource_type, resource_id, ) diff --git a/nexus/auth/src/context.rs b/nexus/auth/src/context.rs index 8f666cbb0e2..797f80c2778 100644 --- a/nexus/auth/src/context.rs +++ b/nexus/auth/src/context.rs @@ -136,6 +136,7 @@ impl OpContext { authn::Actor::SiloUser { silo_user_id, silo_id } => { log.new(o!( "authenticated" => true, + "type" => "silo_user", "silo_user_id" => silo_user_id.to_string(), "silo_id" => silo_id.to_string(), )) @@ -143,8 +144,15 @@ impl OpContext { authn::Actor::UserBuiltin { user_builtin_id } => log.new(o!( "authenticated" => true, + "type" => "user_builtin", "user_builtin_id" => user_builtin_id.to_string(), )), + + authn::Actor::Scim { silo_id } => log.new(o!( + "authenticated" => true, + "type" => "scim", + "silo_id" => silo_id.to_string(), + )), } } else { metadata diff --git a/nexus/db-fixed-data/src/role_assignment.rs b/nexus/db-fixed-data/src/role_assignment.rs index f91094c4ef1..aae06d4b7ae 100644 --- a/nexus/db-fixed-data/src/role_assignment.rs +++ b/nexus/db-fixed-data/src/role_assignment.rs @@ -53,13 +53,5 @@ pub static BUILTIN_ROLE_ASSIGNMENTS: LazyLock> = *FLEET_ID, "external-authenticator", ), - // The "external-scim" user gets the "external-scim" role on the - // sole fleet. This grants them the ability to read SCIM tokens. - RoleAssignment::new_for_builtin_user( - user_builtin::USER_EXTERNAL_SCIM.id, - ResourceType::Fleet, - *FLEET_ID, - "external-scim", - ), ] }); diff --git a/nexus/db-fixed-data/src/user_builtin.rs b/nexus/db-fixed-data/src/user_builtin.rs index ba9fdca769c..1194fe23a53 100644 --- a/nexus/db-fixed-data/src/user_builtin.rs +++ b/nexus/db-fixed-data/src/user_builtin.rs @@ -94,22 +94,11 @@ pub static USER_EXTERNAL_AUTHN: LazyLock = ) }); -/// Internal user used by Nexus when authenticating SCIM requests -pub static USER_EXTERNAL_SCIM: LazyLock = - LazyLock::new(|| { - UserBuiltinConfig::new_static( - "001de000-05e4-4000-8000-000000000004", - "external-scim", - "used by Nexus when authenticating SCIM requests", - ) - }); - #[cfg(test)] mod test { use super::super::assert_valid_typed_uuid; use super::USER_DB_INIT; use super::USER_EXTERNAL_AUTHN; - use super::USER_EXTERNAL_SCIM; use super::USER_INTERNAL_API; use super::USER_INTERNAL_READ; use super::USER_SAGA_RECOVERY; @@ -123,6 +112,5 @@ mod test { assert_valid_typed_uuid(&USER_EXTERNAL_AUTHN.id); assert_valid_typed_uuid(&USER_INTERNAL_READ.id); assert_valid_typed_uuid(&USER_SAGA_RECOVERY.id); - assert_valid_typed_uuid(&USER_EXTERNAL_SCIM.id); } } diff --git a/nexus/db-lookup/src/lookup.rs b/nexus/db-lookup/src/lookup.rs index 4a949503cbd..6fc641a5044 100644 --- a/nexus/db-lookup/src/lookup.rs +++ b/nexus/db-lookup/src/lookup.rs @@ -262,9 +262,11 @@ impl<'a> LookupPath<'a> { SiloUser::PrimaryKey(Root { lookup_root: self }, *silo_user_id), ), - authn::Actor::UserBuiltin { .. } => Err( - Error::non_resourcetype_not_found("could not find silo user"), - ), + authn::Actor::UserBuiltin { .. } | authn::Actor::Scim { .. } => { + Err(Error::non_resourcetype_not_found( + "could not find silo user", + )) + } } } @@ -511,6 +513,18 @@ impl<'a> LookupPath<'a> { { Alert::PrimaryKey(Root { lookup_root: self }, id) } + + /// Select a resource of type [`ScimClientBearerToken`], identified by its + /// UUID. + pub fn scim_client_bearer_token_id<'b>( + self, + id: Uuid, + ) -> ScimClientBearerToken<'b> + where + 'a: 'b, + { + ScimClientBearerToken::PrimaryKey(Root { lookup_root: self }, id) + } } /// Represents the head of the selection path for a resource @@ -909,6 +923,15 @@ lookup_resource! { ] } +lookup_resource! { + name = "ScimClientBearerToken", + ancestors = ["Silo"], + lookup_by_name = false, + soft_deletes = true, + primary_key_columns = [ { column_name = "id", rust_type = Uuid } ], + visible_outside_silo = true // XXX needed? +} + // Helpers for unifying the interfaces around images pub enum ImageLookup<'a> { diff --git a/nexus/db-model/src/audit_log.rs b/nexus/db-model/src/audit_log.rs index 6541f64516e..5708fe7359a 100644 --- a/nexus/db-model/src/audit_log.rs +++ b/nexus/db-model/src/audit_log.rs @@ -24,6 +24,7 @@ use uuid::Uuid; pub enum AuditLogActor { UserBuiltin { user_builtin_id: BuiltInUserUuid }, SiloUser { silo_user_id: SiloUserUuid, silo_id: Uuid }, + Scim { silo_id: Uuid }, Unauthenticated, } @@ -60,6 +61,7 @@ impl_enum_type!( UserBuiltin => b"user_builtin" SiloUser => b"silo_user" Unauthenticated => b"unauthenticated" + Scim => b"scim" ); impl_enum_type!( @@ -139,6 +141,9 @@ impl From for AuditLogEntryInit { Some(silo_id), AuditLogActorKind::SiloUser, ), + AuditLogActor::Scim { silo_id } => { + (None, Some(silo_id), AuditLogActorKind::Scim) + } AuditLogActor::Unauthenticated => { (None, None, AuditLogActorKind::Unauthenticated) } @@ -303,6 +308,14 @@ impl TryFrom for views::AuditLogEntry { silo_id, } } + AuditLogActorKind::Scim => { + let silo_id = entry.actor_silo_id.ok_or_else(|| { + Error::internal_error( + "Scim actor missing actor_silo_id", + ) + })?; + views::AuditLogEntryActor::Scim { silo_id } + } AuditLogActorKind::Unauthenticated => { views::AuditLogEntryActor::Unauthenticated } diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index baa1a408407..30143cdda07 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -71,6 +71,7 @@ mod producer_endpoint; mod project; mod reconfigurator_config; mod rendezvous_debug_dataset; +mod scim_client_bearer_token; mod semver_version; mod serde_time_delta; mod silo_auth_settings; @@ -223,6 +224,7 @@ pub use rendezvous_debug_dataset::*; pub use role_assignment::*; pub use saga_types::*; pub use schema_versions::*; +pub use scim_client_bearer_token::*; pub use semver_version::*; pub use service_kind::*; pub use silo::*; diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index ffc6a7a376b..86d30865827 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock}; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: Version = Version::new(199, 0, 0); +pub const SCHEMA_VERSION: Version = Version::new(200, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock> = LazyLock::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(200, "scim-client-bearer-token"), KnownVersion::new(199, "multicast-pool-support"), KnownVersion::new(198, "add-ip-pool-reservation-type-column"), KnownVersion::new(197, "scim-users-and-groups"), diff --git a/nexus/db-model/src/scim_client_bearer_token.rs b/nexus/db-model/src/scim_client_bearer_token.rs new file mode 100644 index 00000000000..3239bc85299 --- /dev/null +++ b/nexus/db-model/src/scim_client_bearer_token.rs @@ -0,0 +1,53 @@ +// 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 chrono::DateTime; +use chrono::Utc; +use nexus_db_schema::schema::scim_client_bearer_token; +use nexus_types::external_api::views; +use uuid::Uuid; + +/// A SCIM client sends requests to a SCIM provider (in this case, Nexus) using +/// some sort of authentication. Nexus currently only supports Bearer token auth +/// from SCIM clients, and these tokens are stored here. +#[derive(Queryable, Insertable, Clone, Debug, Selectable)] +#[diesel(table_name = scim_client_bearer_token)] +pub struct ScimClientBearerToken { + pub id: Uuid, + + pub time_created: DateTime, + pub time_deleted: Option>, + pub time_expires: Option>, + + pub silo_id: Uuid, + + pub bearer_token: String, +} + +impl ScimClientBearerToken { + pub fn id(&self) -> Uuid { + self.id + } +} + +impl From for views::ScimClientBearerToken { + fn from(t: ScimClientBearerToken) -> views::ScimClientBearerToken { + views::ScimClientBearerToken { + id: t.id, + time_created: t.time_created, + time_expires: t.time_expires, + } + } +} + +impl From for views::ScimClientBearerTokenValue { + fn from(t: ScimClientBearerToken) -> views::ScimClientBearerTokenValue { + views::ScimClientBearerTokenValue { + id: t.id, + time_created: t.time_created, + time_expires: t.time_expires, + bearer_token: t.bearer_token, + } + } +} diff --git a/nexus/db-queries/Cargo.toml b/nexus/db-queries/Cargo.toml index 268157cc724..93b12059e12 100644 --- a/nexus/db-queries/Cargo.toml +++ b/nexus/db-queries/Cargo.toml @@ -23,6 +23,7 @@ diesel.workspace = true diesel-dtrace.workspace = true dropshot.workspace = true futures.workspace = true +hex.workspace = true id-map.workspace = true iddqd.workspace = true internal-dns-resolver.workspace = true @@ -39,6 +40,7 @@ rand.workspace = true ref-cast.workspace = true regex.workspace = true schemars.workspace = true +scim2-rs.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/nexus/db-queries/src/db/datastore/device_auth.rs b/nexus/db-queries/src/db/datastore/device_auth.rs index c49042c7f81..07e7fbc31df 100644 --- a/nexus/db-queries/src/db/datastore/device_auth.rs +++ b/nexus/db-queries/src/db/datastore/device_auth.rs @@ -25,7 +25,6 @@ use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; -use omicron_uuid_kinds::GenericUuid; use uuid::Uuid; impl DataStore { @@ -49,7 +48,7 @@ impl DataStore { let authz_token = authz::DeviceAccessToken::new( authz::FLEET, db_token.id(), - LookupType::ById(db_token.id().into_untyped_uuid()), + LookupType::by_id(db_token.id()), ); // This check might seem superfluous, but (for now at least) only the diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index ce0576cc65f..3667323d493 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -96,6 +96,8 @@ pub mod region_snapshot_replacement; mod rendezvous_debug_dataset; mod role; mod saga; +mod scim; +mod scim_provider_store; mod silo; mod silo_auth_settings; mod silo_group; @@ -143,6 +145,7 @@ pub use region::RegionAllocationFor; pub use region::RegionAllocationParameters; pub use region_snapshot_replacement::NewRegionVolumeId; pub use region_snapshot_replacement::OldSnapshotVolumeId; +pub use scim_provider_store::CrdbScimProviderStore; pub use silo::Discoverability; pub use silo_group::SiloGroup; pub use silo_group::SiloGroupApiOnly; diff --git a/nexus/db-queries/src/db/datastore/scim.rs b/nexus/db-queries/src/db/datastore/scim.rs new file mode 100644 index 00000000000..751a75f5fff --- /dev/null +++ b/nexus/db-queries/src/db/datastore/scim.rs @@ -0,0 +1,192 @@ +// 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/. + +//! [`DataStore`] methods related to SCIM + +use super::DataStore; +use crate::authz; +use crate::context::OpContext; +use crate::db::model::ScimClientBearerToken; +use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::Utc; +use diesel::prelude::*; +use nexus_db_errors::ErrorHandler; +use nexus_db_errors::public_error_from_diesel; +use nexus_db_lookup::LookupPath; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use omicron_common::api::external::LookupType; +use rand::{RngCore, SeedableRng, rngs::StdRng}; +use uuid::Uuid; + +// XXX this is the same as generate_session_token! +fn generate_scim_client_bearer_token() -> String { + let mut rng = StdRng::from_os_rng(); + let mut random_bytes: [u8; 20] = [0; 20]; + rng.fill_bytes(&mut random_bytes); + hex::encode(random_bytes) +} + +impl DataStore { + // SCIM tokens + + pub async fn scim_idp_get_tokens( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + ) -> ListResultVec { + let authz_scim_client_bearer_token_list = + authz::ScimClientBearerTokenList::new(authz_silo.clone()); + opctx + .authorize( + authz::Action::ListChildren, + &authz_scim_client_bearer_token_list, + ) + .await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + let tokens = dsl::scim_client_bearer_token + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::time_deleted.is_null()) + .select(ScimClientBearerToken::as_select()) + .load_async::(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(tokens) + } + + pub async fn scim_idp_create_token( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + ) -> CreateResult { + let authz_scim_client_bearer_token_list = + authz::ScimClientBearerTokenList::new(authz_silo.clone()); + opctx + .authorize( + authz::Action::CreateChild, + &authz_scim_client_bearer_token_list, + ) + .await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + let new_token = ScimClientBearerToken { + id: Uuid::new_v4(), + time_created: Utc::now(), + time_deleted: None, + // TODO: allow setting an expiry? have a silo default? + time_expires: None, + silo_id: authz_silo.id(), + bearer_token: generate_scim_client_bearer_token(), + }; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::insert_into(dsl::scim_client_bearer_token) + .values(new_token.clone()) + .execute_async(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(new_token) + } + + pub async fn scim_idp_get_token_by_id( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + token_id: Uuid, + ) -> LookupResult { + let (_, authz_token) = LookupPath::new(opctx, self) + .scim_client_bearer_token_id(token_id) + .lookup_for(authz::Action::Read) + .await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + let token = dsl::scim_client_bearer_token + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::id.eq(authz_token.id())) + .filter(dsl::time_deleted.is_null()) + .select(ScimClientBearerToken::as_select()) + .first_async::(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(token) + } + + pub async fn scim_idp_delete_token_by_id( + &self, + opctx: &OpContext, + authz_silo: &authz::Silo, + token_id: Uuid, + ) -> DeleteResult { + let (_, authz_token) = LookupPath::new(opctx, self) + .scim_client_bearer_token_id(token_id) + .lookup_for(authz::Action::Delete) + .await?; + + let conn = self.pool_connection_authorized(opctx).await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::update(dsl::scim_client_bearer_token) + .filter(dsl::silo_id.eq(authz_silo.id())) + .filter(dsl::id.eq(authz_token.id())) + .filter(dsl::time_deleted.is_null()) + .set(dsl::time_deleted.eq(Utc::now())) + .execute_async(&*conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(()) + } + + pub async fn scim_lookup_token_by_bearer( + &self, + opctx: &OpContext, + bearer_token: String, + ) -> LookupResult> { + let conn = self.pool_connection_authorized(opctx).await?; + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + let maybe_token: Option = + dsl::scim_client_bearer_token + .filter(dsl::bearer_token.eq(bearer_token)) + .filter(dsl::time_deleted.is_null()) + .select(ScimClientBearerToken::as_select()) + .first_async(&*conn) + .await + .optional() + .map_err(|e| { + public_error_from_diesel(e, ErrorHandler::Server) + })?; + + let Some(token) = maybe_token else { + return Ok(None); + }; + + // we have to construct the authz resource after the lookup because we + // don't have its ID on hand until then + let authz_token = authz::ScimClientBearerToken::new( + authz::Silo::new( + authz::FLEET, + token.silo_id, + LookupType::by_id(token.silo_id), + ), + token.id(), + LookupType::ById(token.id()), + ); + + opctx.authorize(authz::Action::Read, &authz_token).await?; + + Ok(Some(token)) + } +} diff --git a/nexus/db-queries/src/db/datastore/scim_provider_store.rs b/nexus/db-queries/src/db/datastore/scim_provider_store.rs new file mode 100644 index 00000000000..e07b4c4b67d --- /dev/null +++ b/nexus/db-queries/src/db/datastore/scim_provider_store.rs @@ -0,0 +1,130 @@ +// 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/. + +//! scim2-rs uses the pattern of implementing a SCIM "provider" over something +//! that implements a "provider store" trait that durably stores the SCIM +//! related information. Nexus uses cockroachdb as the provider store. + +use super::DataStore; +use anyhow::anyhow; +use std::sync::Arc; +use uuid::Uuid; + +use scim2_rs::CreateGroupRequest; +use scim2_rs::CreateUserRequest; +use scim2_rs::FilterOp; +use scim2_rs::Group; +use scim2_rs::ProviderStore; +use scim2_rs::ProviderStoreDeleteResult; +use scim2_rs::ProviderStoreError; +use scim2_rs::StoredParts; +use scim2_rs::User; + +// XXX temporary until SCIM impl PR +#[allow(dead_code)] +pub struct CrdbScimProviderStore { + silo_id: Uuid, + datastore: Arc, +} + +impl CrdbScimProviderStore { + pub fn new(silo_id: Uuid, datastore: Arc) -> Self { + CrdbScimProviderStore { silo_id, datastore } + } +} + +#[async_trait::async_trait] +impl ProviderStore for CrdbScimProviderStore { + async fn get_user_by_id( + &self, + _user_id: &str, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn create_user( + &self, + _user_request: CreateUserRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn list_users( + &self, + _filter: Option, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn replace_user( + &self, + _user_id: &str, + _user_request: CreateUserRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn delete_user_by_id( + &self, + _user_id: &str, + ) -> Result { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn get_group_by_id( + &self, + _group_id: &str, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn create_group( + &self, + _group_request: CreateGroupRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn list_groups( + &self, + _filter: Option, + ) -> Result>, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn replace_group( + &self, + _group_id: &str, + _group_request: CreateGroupRequest, + ) -> Result, ProviderStoreError> { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } + + async fn delete_group_by_id( + &self, + _group_id: &str, + ) -> Result { + return Err(ProviderStoreError::StoreError(anyhow!( + "not implemented!" + ))); + } +} diff --git a/nexus/db-queries/src/db/datastore/silo_user.rs b/nexus/db-queries/src/db/datastore/silo_user.rs index b2a9832df04..0bdc8125fc5 100644 --- a/nexus/db-queries/src/db/datastore/silo_user.rs +++ b/nexus/db-queries/src/db/datastore/silo_user.rs @@ -860,7 +860,6 @@ impl DataStore { &authn::USER_INTERNAL_READ, &authn::USER_EXTERNAL_AUTHN, &authn::USER_SAGA_RECOVERY, - &authn::USER_EXTERNAL_SCIM, ] .iter() .map(|u| { diff --git a/nexus/db-queries/src/policy_test/mod.rs b/nexus/db-queries/src/policy_test/mod.rs index 67879f61877..15f67857aec 100644 --- a/nexus/db-queries/src/policy_test/mod.rs +++ b/nexus/db-queries/src/policy_test/mod.rs @@ -145,7 +145,7 @@ async fn test_iam_prep( /// users and role assignments. #[tokio::test(flavor = "multi_thread")] async fn test_iam_roles_behavior() { - let logctx = dev::test_setup_log("test_iam_roles"); + let logctx = dev::test_setup_log("test_iam_roles_behavior"); let db = TestDatabase::new_with_datastore(&logctx.log).await; let (opctx, datastore) = (db.opctx(), db.datastore()); diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index e7c7da87b3d..631b657f24b 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -264,6 +264,7 @@ impl_dyn_authorized_resource_for_resource!(authz::PhysicalDisk); impl_dyn_authorized_resource_for_resource!(authz::Project); impl_dyn_authorized_resource_for_resource!(authz::ProjectImage); impl_dyn_authorized_resource_for_resource!(authz::SamlIdentityProvider); +impl_dyn_authorized_resource_for_resource!(authz::ScimClientBearerToken); impl_dyn_authorized_resource_for_resource!(authz::Service); impl_dyn_authorized_resource_for_resource!(authz::Silo); impl_dyn_authorized_resource_for_resource!(authz::SiloGroup); @@ -380,3 +381,23 @@ impl DynAuthorizedResource for authz::SiloUserTokenList { format!("{}: token list", self.silo_user().resource_name()) } } + +impl DynAuthorizedResource for authz::ScimClientBearerTokenList { + fn do_authorize<'a, 'b>( + &'a self, + opctx: &'b OpContext, + action: authz::Action, + ) -> BoxFuture<'a, Result<(), Error>> + where + 'b: 'a, + { + opctx.authorize(action, self).boxed() + } + + fn resource_name(&self) -> String { + format!( + "{}: scim client bearer token list", + self.silo().resource_name() + ) + } +} diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index dc88e0498ba..dcb77e100a7 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -302,6 +302,16 @@ async fn make_silo( let create_project_users = first_branch && i == 0; make_project(builder, &silo, &project_name, create_project_users).await; } + + let scim_client_bearer_token_id = + "7885144e-9c75-47f7-a97d-7dfc58e1186c".parse().unwrap(); + + builder.new_resource(authz::ScimClientBearerToken::new( + silo.clone(), + scim_client_bearer_token_id, + LookupType::by_id(scim_client_bearer_token_id), + )); + builder.new_resource(authz::ScimClientBearerTokenList::new(silo.clone())); } /// Helper for `make_resources()` that constructs a small Project hierarchy diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 4d7478c7e32..d85802d2c61 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -768,6 +768,20 @@ resource: InternetGatewayIpAddress "silo1-proj2-igw1-address1" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Silo "silo2" USER Q R LC RP M MP CC D @@ -1160,6 +1174,20 @@ resource: InternetGatewayIpAddress "silo2-proj1-igw1-address1" silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: ScimClientBearerToken id "7885144e-9c75-47f7-a97d-7dfc58e1186c" + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✔ ✔ ✔ ✔ ✔ ✔ ✔ + fleet-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + fleet-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Rack id "c037e882-8b6d-c8b5-bef4-97e848eb0a50" USER Q R LC RP M MP CC D diff --git a/nexus/db-schema/src/schema.rs b/nexus/db-schema/src/schema.rs index 654a11b8f3e..cc33ea30a93 100644 --- a/nexus/db-schema/src/schema.rs +++ b/nexus/db-schema/src/schema.rs @@ -2792,3 +2792,17 @@ table! { result_kind -> crate::enums::AuditLogResultKindEnum, } } + +table! { + scim_client_bearer_token (id) { + id -> Uuid, + + time_created -> Timestamptz, + time_deleted -> Nullable, + time_expires -> Nullable, + + silo_id -> Uuid, + + bearer_token -> Text, + } +} diff --git a/nexus/examples/config-second.toml b/nexus/examples/config-second.toml index 138c3f4e1d2..2de7bb187ca 100644 --- a/nexus/examples/config-second.toml +++ b/nexus/examples/config-second.toml @@ -17,7 +17,7 @@ session_absolute_timeout_minutes = 1440 # 24 hours # List of authentication schemes to support. [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe diff --git a/nexus/examples/config.toml b/nexus/examples/config.toml index e314d8f6152..e95e40496d7 100644 --- a/nexus/examples/config.toml +++ b/nexus/examples/config.toml @@ -10,7 +10,7 @@ session_absolute_timeout_minutes = 1440 # 24 hours # List of authentication schemes to support. [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 9f84bca8d18..da5f2eee772 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -274,7 +274,6 @@ saml_identity_provider_create POST /v1/system/identity-providers/ saml_identity_provider_view GET /v1/system/identity-providers/saml/{provider} scim_token_create POST /v1/system/scim/tokens scim_token_delete DELETE /v1/system/scim/tokens/{token_id} -scim_token_delete_all DELETE /v1/system/scim/tokens scim_token_list GET /v1/system/scim/tokens scim_token_view GET /v1/system/scim/tokens/{token_id} silo_create POST /v1/system/silos diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 510dbe4295e..66769c9a971 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -650,19 +650,6 @@ pub trait NexusExternalApi { query_params: Query, ) -> Result; - /// Delete all SCIM tokens - /// - /// Specify the silo by name or ID using the `silo` query parameter. - #[endpoint { - method = DELETE, - path = "/v1/system/scim/tokens", - tags = ["system/silos"], - }] - async fn scim_token_delete_all( - rqctx: RequestContext, - query_params: Query, - ) -> Result; - // SCIM user endpoints // XXX is "silos" the correct tag? diff --git a/nexus/src/app/audit_log.rs b/nexus/src/app/audit_log.rs index 654feade95b..55d77b718c2 100644 --- a/nexus/src/app/audit_log.rs +++ b/nexus/src/app/audit_log.rs @@ -64,6 +64,9 @@ impl super::Nexus { silo_user_id: *silo_user_id, silo_id: *silo_id, }, + Some(nexus_auth::authn::Actor::Scim { silo_id }) => { + AuditLogActor::Scim { silo_id: *silo_id } + } None => AuditLogActor::Unauthenticated, }; @@ -121,7 +124,8 @@ impl super::Nexus { // practically speaking, there is currently no operation that will // cause this method to be called with a built-in user AuditLogActor::UserBuiltin { .. } - | AuditLogActor::SiloUser { .. } => { + | AuditLogActor::SiloUser { .. } + | AuditLogActor::Scim { .. } => { opctx.authn.scheme_used().map(|s| s.to_string()) } // if we tried to pull it off the opctx this would be None anyway, diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 9b5f0494455..f4cf081305b 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -876,17 +876,6 @@ impl Nexus { ) } - /// Returns an [`OpContext`] used for authenticating SCIM requests - pub fn opctx_external_scim(&self) -> OpContext { - OpContext::for_background( - self.log.new(o!("component" => "ExternalScim")), - Arc::clone(&self.authz), - authn::Context::external_scim(), - Arc::clone(&self.db_datastore) - as Arc, - ) - } - /// Used as the body of a "stub" endpoint -- one that's currently /// unimplemented but that we eventually intend to implement /// diff --git a/nexus/src/app/scim.rs b/nexus/src/app/scim.rs index 71aa8a638f7..d9b6a3bc5e1 100644 --- a/nexus/src/app/scim.rs +++ b/nexus/src/app/scim.rs @@ -4,12 +4,18 @@ //! SCIM endpoints +use crate::db::model::UserProvisionType; + +use anyhow::anyhow; +use chrono::Utc; use dropshot::Body; use dropshot::HttpError; use http::Response; +use http::StatusCode; use nexus_db_lookup::lookup; -use nexus_db_queries::authz; +use nexus_db_queries::authn::{Actor, Reason}; use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::datastore::CrdbScimProviderStore; use nexus_types::external_api::views; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DeleteResult; @@ -18,11 +24,6 @@ use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use uuid::Uuid; -// XXX temporary for stub PR -use crate::app::Unimpl; -use omicron_common::api::external::LookupType; -use omicron_common::api::external::ResourceType; - impl super::Nexus { // SCIM tokens @@ -31,15 +32,12 @@ impl super::Nexus { opctx: &OpContext, silo_lookup: &lookup::Silo<'_>, ) -> ListResultVec { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = - LookupType::ByOther(String::from("scim_idp_get_tokens")); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + let tokens = + self.datastore().scim_idp_get_tokens(opctx, &authz_silo).await?; + + Ok(tokens.into_iter().map(|t| t.into()).collect()) } pub(crate) async fn scim_idp_create_token( @@ -47,15 +45,12 @@ impl super::Nexus { opctx: &OpContext, silo_lookup: &lookup::Silo<'_>, ) -> CreateResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::Modify).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; + + let token = + self.datastore().scim_idp_create_token(opctx, &authz_silo).await?; - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = - LookupType::ByOther(String::from("scim_idp_create_token")); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + Ok(token.into()) } pub(crate) async fn scim_idp_get_token_by_id( @@ -64,14 +59,14 @@ impl super::Nexus { silo_lookup: &lookup::Silo<'_>, token_id: Uuid, ) -> LookupResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::Read).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = LookupType::by_id(token_id); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + let token = self + .datastore() + .scim_idp_get_token_by_id(opctx, &authz_silo, token_id) + .await?; + + Ok(token.into()) } pub(crate) async fn scim_idp_delete_token_by_id( @@ -80,135 +75,274 @@ impl super::Nexus { silo_lookup: &lookup::Silo<'_>, token_id: Uuid, ) -> DeleteResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::Delete).await?; + let (.., authz_silo, _) = silo_lookup.fetch().await?; + + self.datastore() + .scim_idp_delete_token_by_id(opctx, &authz_silo, token_id) + .await?; - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = LookupType::by_id(token_id); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + Ok(()) } - pub(crate) async fn scim_idp_delete_tokens_for_silo( + // SCIM client authentication + + pub(crate) async fn scim_token_actor( &self, opctx: &OpContext, - silo_lookup: &lookup::Silo<'_>, - ) -> DeleteResult { - let (.., _authz_silo, _) = - silo_lookup.fetch_for(authz::Action::ListChildren).await?; + token: String, + ) -> Result { + let Some(bearer_token) = self + .datastore() + .scim_lookup_token_by_bearer(opctx, token.clone()) + .await + .map_err(|e| Reason::UnknownError { source: e })? + else { + return Err(Reason::UnknownActor { + actor: "scim bearer token".to_string(), + }); + }; + + if let Some(time_expires) = &bearer_token.time_expires { + let now = Utc::now(); + if now > *time_expires { + return Err(Reason::BadCredentials { + actor: Actor::Scim { silo_id: bearer_token.silo_id }, + source: anyhow!( + "token expired at {time_expires} (current time: {now})" + ), + }); + } + } + + // Validate that silo has the SCIM user provision type + let (_, db_silo) = { + self.silo_lookup(opctx, bearer_token.silo_id.into()) + .map_err(|e| Reason::UnknownError { source: e })? + .fetch() + .await + .map_err(|e| Reason::UnknownError { source: e })? + }; - let resource_type = ResourceType::ScimClientBearerToken; - let lookup_type = LookupType::ByOther(String::from( - "scim_idp_delete_tokens_for_silo", - )); - let not_found_error = lookup_type.into_not_found(resource_type); - let unimp = Unimpl::ProtectedLookup(not_found_error); - Err(self.unimplemented_todo(opctx, unimp).await) + if db_silo.user_provision_type != UserProvisionType::Scim { + // This should basically be impossible if the bearer token lookup + // returned something, but double check anyway. + return Err(Reason::BadCredentials { + actor: Actor::Scim { silo_id: bearer_token.silo_id }, + source: anyhow!( + "silo {} not a SCIM silo!", + bearer_token.silo_id, + ), + }); + } + + Ok(Actor::Scim { silo_id: bearer_token.silo_id }) + } + + /// For an authenticataed Actor::Scim, return a scim2_rs::Provider + pub(crate) async fn scim_get_provider_from_opctx( + &self, + opctx: &OpContext, + ) -> LookupResult> { + match opctx.authn.actor() { + Some(Actor::Scim { silo_id }) => Ok(scim2_rs::Provider::new( + self.log.new(slog::o!( + "component" => "scim2_rs::Provider", + "silo" => silo_id.to_string(), + )), + CrdbScimProviderStore::new(*silo_id, self.datastore().clone()), + )), + + _ => Err(Error::Unauthenticated { + internal_message: "not an Actor::Scim".to_string(), + }), + } } // SCIM implementation - // XXX cannot use [`unimplemented_todo`] here, there's no opctx pub async fn scim_v2_list_users( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, + opctx: &OpContext, + query: scim2_rs::QueryParams, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.list_users(query).await { + Ok(response) => response.to_http_response(), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_get_user_by_id( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, - _user_id: String, + opctx: &OpContext, + query: scim2_rs::QueryParams, + user_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.get_user_by_id(query, &user_id).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_create_user( &self, - _request: &dropshot::RequestInfo, - _body: scim2_rs::CreateUserRequest, + opctx: &OpContext, + body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.create_user(body).await { + Ok(response) => response.to_http_response(StatusCode::CREATED), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_replace_user( &self, - _request: &dropshot::RequestInfo, - _user_id: String, - _body: scim2_rs::CreateUserRequest, + opctx: &OpContext, + user_id: String, + body: scim2_rs::CreateUserRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.replace_user(&user_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_patch_user( &self, - _request: &dropshot::RequestInfo, - _user_id: String, - _body: scim2_rs::PatchRequest, + opctx: &OpContext, + user_id: String, + body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.patch_user(&user_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_delete_user( &self, - _request: &dropshot::RequestInfo, - _user_id: String, + opctx: &OpContext, + user_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.delete_user(&user_id).await { + Ok(response) => Ok(response), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_list_groups( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, + opctx: &OpContext, + query: scim2_rs::QueryParams, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.list_groups(query).await { + Ok(response) => response.to_http_response(), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_get_group_by_id( &self, - _request: &dropshot::RequestInfo, - _query: scim2_rs::QueryParams, - _group_id: String, + opctx: &OpContext, + query: scim2_rs::QueryParams, + group_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.get_group_by_id(query, &group_id).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_create_group( &self, - _request: &dropshot::RequestInfo, - _body: scim2_rs::CreateGroupRequest, + opctx: &OpContext, + body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.create_group(body).await { + Ok(response) => response.to_http_response(StatusCode::CREATED), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_replace_group( &self, - _request: &dropshot::RequestInfo, - _group_id: String, - _body: scim2_rs::CreateGroupRequest, + opctx: &OpContext, + group_id: String, + body: scim2_rs::CreateGroupRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.replace_group(&group_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_patch_group( &self, - _request: &dropshot::RequestInfo, - _group_id: String, - _body: scim2_rs::PatchRequest, + opctx: &OpContext, + group_id: String, + body: scim2_rs::PatchRequest, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.patch_group(&group_id, body).await { + Ok(response) => response.to_http_response(StatusCode::OK), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } pub async fn scim_v2_delete_group( &self, - _request: &dropshot::RequestInfo, - _group_id: String, + opctx: &OpContext, + group_id: String, ) -> Result, HttpError> { - Err(Error::internal_error("endpoint is not implemented").into()) + let provider = self.scim_get_provider_from_opctx(opctx).await?; + + let result = match provider.delete_group(&group_id).await { + Ok(response) => Ok(response), + Err(error) => error.to_http_response(), + }; + + result.map_err(HttpError::from) } } diff --git a/nexus/src/context.rs b/nexus/src/context.rs index aaab00036cd..b2684b1cc05 100644 --- a/nexus/src/context.rs +++ b/nexus/src/context.rs @@ -6,6 +6,7 @@ use super::Nexus; use crate::saga_interface::SagaContext; use async_trait::async_trait; use authn::external::HttpAuthnScheme; +use authn::external::scim::HttpAuthnScimToken; use authn::external::session_cookie::HttpAuthnSessionCookie; use authn::external::spoof::HttpAuthnSpoof; use authn::external::token::HttpAuthnToken; @@ -141,6 +142,7 @@ impl ServerContext { Box::new(HttpAuthnSessionCookie) } SchemeName::AccessToken => Box::new(HttpAuthnToken), + SchemeName::ScimToken => Box::new(HttpAuthnScimToken), }, ) .collect(); @@ -495,3 +497,14 @@ impl SessionStore for ServerContext { self.console_config.session_absolute_timeout } } + +#[async_trait] +impl authn::external::scim::ScimTokenContext for ServerContext { + async fn scim_token_actor( + &self, + token: String, + ) -> Result { + let opctx = self.nexus.opctx_external_authn(); + self.nexus.scim_token_actor(opctx, token).await + } +} diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 4e96db2d3eb..6b1b75f15c8 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1031,59 +1031,20 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn scim_token_delete_all( - rqctx: RequestContext, - query_params: Query, - ) -> Result { - let apictx = rqctx.context(); - let handler = async { - let opctx = - crate::context::op_context_for_external_api(&rqctx).await?; - let nexus = &apictx.context.nexus; - let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; - - let result = async { - let query = query_params.into_inner(); - let silo_lookup = nexus.silo_lookup(&opctx, query.silo)?; - - nexus - .scim_idp_delete_tokens_for_silo(&opctx, &silo_lookup) - .await?; - - Ok(HttpResponseDeleted()) - } - .await; - - let _ = - nexus.audit_log_entry_complete(&opctx, &audit, &result).await; - result - }; - apictx - .context - .external_latencies - .instrument_dropshot_handler(&rqctx, handler) - .await - } - async fn scim_v2_list_users( rqctx: RequestContext, query_params: Query, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - - // SCIM operations are authenticated by resolving a token (that does - // _not_ resolve to any Actor) to a silo-specific SCIM server - // implementation. There isn't any opctx to use, so the "external - // authentication" one is used here for audit purposes. - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); - nexus.scim_v2_list_users(&rqctx.request, query).await + nexus.scim_v2_list_users(&opctx, query).await } .await; @@ -1105,21 +1066,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); let path_params = path_params.into_inner(); nexus - .scim_v2_get_user_by_id( - &rqctx.request, - query, - path_params.user_id, - ) + .scim_v2_get_user_by_id(&opctx, query, path_params.user_id) .await } .await; @@ -1141,15 +1098,13 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { - nexus - .scim_v2_create_user(&rqctx.request, body.into_inner()) - .await + nexus.scim_v2_create_user(&opctx, body.into_inner()).await } .await; @@ -1171,17 +1126,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_replace_user( - &rqctx.request, + &opctx, path_params.user_id, body.into_inner(), ) @@ -1207,17 +1162,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_patch_user( - &rqctx.request, + &opctx, path_params.user_id, body.into_inner(), ) @@ -1242,17 +1197,15 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); - nexus - .scim_v2_delete_user(&rqctx.request, path_params.user_id) - .await + nexus.scim_v2_delete_user(&opctx, path_params.user_id).await } .await; @@ -1273,15 +1226,15 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); - nexus.scim_v2_list_groups(&rqctx.request, query).await + nexus.scim_v2_list_groups(&opctx, query).await } .await; @@ -1303,10 +1256,10 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let query = query_params.into_inner(); @@ -1314,7 +1267,7 @@ impl NexusExternalApi for NexusExternalApiImpl { nexus .scim_v2_get_group_by_id( - &rqctx.request, + &opctx, query, path_params.group_id, ) @@ -1339,15 +1292,13 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { - nexus - .scim_v2_create_group(&rqctx.request, body.into_inner()) - .await + nexus.scim_v2_create_group(&opctx, body.into_inner()).await } .await; @@ -1369,17 +1320,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_replace_group( - &rqctx.request, + &opctx, path_params.group_id, body.into_inner(), ) @@ -1405,17 +1356,17 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); nexus .scim_v2_patch_group( - &rqctx.request, + &opctx, path_params.group_id, body.into_inner(), ) @@ -1440,17 +1391,15 @@ impl NexusExternalApi for NexusExternalApiImpl { ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - let opctx = nexus.opctx_external_scim(); - let audit = - nexus.audit_log_entry_init_unauthed(&opctx, &rqctx).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; let result = async { let path_params = path_params.into_inner(); - nexus - .scim_v2_delete_group(&rqctx.request, path_params.group_id) - .await + nexus.scim_v2_delete_group(&opctx, path_params.group_id).await } .await; diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index ab4905267e6..7544d30b941 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -660,6 +660,17 @@ impl<'a> NexusRequest<'a> { ) } + /// Returns a new `NexusRequest` suitable for `POST $uri` with no body + pub fn objects_post_no_body( + testctx: &'a ClientTestContext, + uri: &str, + ) -> Self { + NexusRequest::new( + RequestBuilder::new(testctx, http::Method::POST, uri) + .expect_status(Some(http::StatusCode::CREATED)), + ) + } + /// Returns a new `NexusRequest` suitable for `GET $uri` pub fn object_get(testctx: &'a ClientTestContext, uri: &str) -> Self { NexusRequest::new( diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 5f9f59b039f..3c9044740bc 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -148,6 +148,24 @@ where .unwrap() } +pub async fn object_create_no_body( + client: &ClientTestContext, + path: &str, +) -> OutputType +where + OutputType: serde::de::DeserializeOwned, +{ + NexusRequest::objects_post_no_body(client, path) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap_or_else(|e| { + panic!("failed to make \"POST\" request to {path}: {e}") + }) + .parsed_body() + .unwrap() +} + /// Make a POST, assert status code, return error response body pub async fn object_create_error( client: &ClientTestContext, diff --git a/nexus/tests/config.test.toml b/nexus/tests/config.test.toml index 9ea7467c417..e629fd435c9 100644 --- a/nexus/tests/config.test.toml +++ b/nexus/tests/config.test.toml @@ -10,7 +10,7 @@ session_absolute_timeout_minutes = 1440 # 24 hours # List of authentication schemes to support. [authn] -schemes_external = ["spoof", "session_cookie", "access_token"] +schemes_external = ["spoof", "session_cookie", "access_token", "scim_token"] # # NOTE: for the test suite, if mode = "file", the file path MUST be the sentinel diff --git a/nexus/tests/integration_tests/authn_http.rs b/nexus/tests/integration_tests/authn_http.rs index 7a50c137f61..f9262490090 100644 --- a/nexus/tests/integration_tests/authn_http.rs +++ b/nexus/tests/integration_tests/authn_http.rs @@ -438,6 +438,8 @@ async fn whoami_get( Actor::SiloUser { silo_user_id, .. } => silo_user_id.to_string(), Actor::UserBuiltin { user_builtin_id } => user_builtin_id.to_string(), + + Actor::Scim { silo_id } => format!("scim for {silo_id}"), }); let authenticated = actor.is_some(); let schemes_tried = diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index fddba783a20..cb9e9a82b61 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1276,6 +1276,18 @@ pub static AUDIT_LOG_URL: LazyLock = LazyLock::new(|| { String::from("/v1/system/audit-log?start_time=2025-01-01T00:00:00Z") }); +pub static SCIM_TOKENS_URL: LazyLock = LazyLock::new(|| { + format!("/v1/system/scim/tokens?silo={}", DEFAULT_SILO.identity().name,) +}); + +pub static SCIM_TOKEN_URL: LazyLock = LazyLock::new(|| { + format!( + "/v1/system/scim/tokens/{}?silo={}", + "7885144e-9c75-47f7-a97d-7dfc58e1186c", + DEFAULT_SILO.identity().name, + ) +}); + /// Describes an API endpoint to be verified by the "unauthorized" test /// /// These structs are also used to check whether we're covering all endpoints in @@ -3045,6 +3057,25 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, + // SCIM client tokens + VerifyEndpoint { + url: &SCIM_TOKENS_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Post(serde_json::Value::Null), + ], + }, + VerifyEndpoint { + url: &SCIM_TOKEN_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + ], + }, ] }, ); diff --git a/nexus/tests/integration_tests/scim.rs b/nexus/tests/integration_tests/scim.rs index 60ee0453008..f63918848a4 100644 --- a/nexus/tests/integration_tests/scim.rs +++ b/nexus/tests/integration_tests/scim.rs @@ -2,22 +2,31 @@ // 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 crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; +use crate::integration_tests::saml::SAML_RESPONSE_IDP_DESCRIPTOR; +use crate::integration_tests::saml::SAML_RESPONSE_WITH_GROUPS; +use async_bb8_diesel::AsyncRunQueryDsl; +use base64::Engine; +use chrono::Utc; +use http::StatusCode; +use http::method::Method; +use nexus_db_queries::authn::USER_TEST_PRIVILEGED; use nexus_db_queries::authn::silos::{IdentityProviderType, SamlLoginPost}; +use nexus_db_queries::db::model::ScimClientBearerToken; use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; -use nexus_test_utils::resource_helpers::{create_silo, object_create}; +use nexus_test_utils::resource_helpers::create_silo; +use nexus_test_utils::resource_helpers::grant_iam; +use nexus_test_utils::resource_helpers::object_create; +use nexus_test_utils::resource_helpers::object_create_no_body; +use nexus_test_utils::resource_helpers::object_delete; +use nexus_test_utils::resource_helpers::object_get; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::views::{self, Silo}; use nexus_types::external_api::{params, shared}; +use nexus_types::identity::Asset; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_nexus::TestInterfaces; - -use base64::Engine; -use http::StatusCode; -use http::method::Method; - -use crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; -use crate::integration_tests::saml::SAML_RESPONSE_IDP_DESCRIPTOR; -use crate::integration_tests::saml::SAML_RESPONSE_WITH_GROUPS; +use uuid::Uuid; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -204,3 +213,356 @@ async fn test_no_jit_for_saml_scim_silos(cptestctx: &ControlPlaneTestContext) { .await .expect("expected 401"); } + +#[nexus_test] +async fn test_scim_client_token_crud(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Create a Silo, then grant the PrivilegedUser the Admin role on it + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + USER_TEST_PRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Initially, there should be no tokens created during silo create. + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert!(tokens.is_empty()); + + // Fleet admins can create SCIM client tokens + + let created_token_1: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + // Now there's one! + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].id, created_token_1.id); + + // Get that specific token + + let token: views::ScimClientBearerToken = object_get( + client, + &format!( + "/v1/system/scim/tokens/{}?silo={SILO_NAME}", + created_token_1.id, + ), + ) + .await; + + assert_eq!(token.id, created_token_1.id); + + // Create a new token + + let created_token_2: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + // Now there's two! + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 2); + assert!(tokens.iter().any(|token| token.id == created_token_1.id)); + assert!(tokens.iter().any(|token| token.id == created_token_2.id)); + + // Create one more + + let created_token_3: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 3); + assert!(tokens.iter().any(|token| token.id == created_token_1.id)); + assert!(tokens.iter().any(|token| token.id == created_token_2.id)); + assert!(tokens.iter().any(|token| token.id == created_token_3.id)); + + // Delete one + + object_delete( + client, + &format!( + "/v1/system/scim/tokens/{}?silo={SILO_NAME}", + created_token_1.id, + ), + ) + .await; + + // Check there's two + + let tokens: Vec = + object_get(client, &format!("/v1/system/scim/tokens?silo={SILO_NAME}")) + .await; + + assert_eq!(tokens.len(), 2); + assert!(tokens.iter().any(|token| token.id == created_token_2.id)); + assert!(tokens.iter().any(|token| token.id == created_token_3.id)); +} + +#[nexus_test] +async fn test_scim_client_token_tenancy(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Create two Silos, then grant the PrivilegedUser the Admin role on both + + const SILO_1_NAME: &str = "saml-scim-silo-1"; + const SILO_2_NAME: &str = "saml-scim-silo-2"; + + create_silo(&client, SILO_1_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + create_silo(&client, SILO_2_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_1_NAME}"), + shared::SiloRole::Admin, + USER_TEST_PRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_2_NAME}"), + shared::SiloRole::Admin, + USER_TEST_PRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Initially, there should be no tokens created during silo create. + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), + ) + .await; + + assert!(tokens.is_empty()); + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_2_NAME}"), + ) + .await; + + assert!(tokens.is_empty()); + + // Create a token in one of the Silos + + let _created_token_1: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), + ) + .await; + + // Now there's one but only in the first Silo + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_1_NAME}"), + ) + .await; + + assert!(!tokens.is_empty()); + + let tokens: Vec = object_get( + client, + &format!("/v1/system/scim/tokens?silo={SILO_2_NAME}"), + ) + .await; + + assert!(tokens.is_empty()); +} + +#[nexus_test] +async fn test_scim_client_token_bearer_auth( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a Silo, then grant the PrivilegedUser the Admin role on it + + const SILO_NAME: &str = "saml-scim-silo"; + create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlScim) + .await; + + grant_iam( + client, + &format!("/v1/system/silos/{SILO_NAME}"), + shared::SiloRole::Admin, + USER_TEST_PRIVILEGED.id(), + AuthnMode::PrivilegedUser, + ) + .await; + + // Create a token + + let created_token: views::ScimClientBearerTokenValue = + object_create_no_body( + client, + &format!("/v1/system/scim/tokens?silo={SILO_NAME}"), + ) + .await; + + // Check that we can get a SCIM provider using that token + // XXX this will 500 until the final impl PR, but it should not 401 + + RequestBuilder::new(client, Method::GET, "/scim/v2/Users") + .header( + http::header::AUTHORIZATION, + format!("Bearer oxide-scim-{}", created_token.bearer_token), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::INTERNAL_SERVER_ERROR)) + .execute() + .await + .expect("expected 500"); +} + +#[nexus_test] +async fn test_scim_client_no_auth_with_expired_token( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + // Create a Silo, then insert an expired token into it + + const SILO_NAME: &str = "saml-scim-silo"; + + let silo = create_silo( + &client, + SILO_NAME, + true, + shared::SiloIdentityMode::SamlScim, + ) + .await; + + // Manually create an expired token + + { + let now = Utc::now(); + + let new_token = ScimClientBearerToken { + id: Uuid::new_v4(), + time_created: now, + time_deleted: None, + time_expires: Some(now), + silo_id: silo.identity.id, + bearer_token: String::from("testpost"), + }; + + let conn = nexus.datastore().pool_connection_for_tests().await.unwrap(); + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::insert_into(dsl::scim_client_bearer_token) + .values(new_token.clone()) + .execute_async(&*conn) + .await + .unwrap(); + } + + // This should 401 + + RequestBuilder::new(client, Method::GET, "/scim/v2/Users") + .header( + http::header::AUTHORIZATION, + String::from("Bearer oxide-scim-testpost"), + ) + .allow_non_dropshot_errors() + .expect_status(Some(StatusCode::UNAUTHORIZED)) + .execute() + .await + .expect("expected 401"); +} + +/// Test that a SCIM authenticated actor cannot read a Silo's projects +#[nexus_test] +async fn test_scim_client_no_read_project(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let nexus = &cptestctx.server.server_context().nexus; + + // Create a Silo, then insert an expired token into it + + const SILO_NAME: &str = "saml-scim-silo"; + + let silo = create_silo( + &client, + SILO_NAME, + true, + shared::SiloIdentityMode::SamlScim, + ) + .await; + + // Manually create a token + + { + let now = Utc::now(); + + let new_token = ScimClientBearerToken { + id: Uuid::new_v4(), + time_created: now, + time_deleted: None, + time_expires: None, + silo_id: silo.identity.id, + bearer_token: String::from("testpost"), + }; + + let conn = nexus.datastore().pool_connection_for_tests().await.unwrap(); + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::insert_into(dsl::scim_client_bearer_token) + .values(new_token.clone()) + .execute_async(&*conn) + .await + .unwrap(); + } + + // This should 404 + + RequestBuilder::new(client, Method::GET, "/v1/projects") + .header( + http::header::AUTHORIZATION, + String::from("Bearer oxide-scim-testpost"), + ) + .expect_status(Some(StatusCode::NOT_FOUND)) + .execute() + .await + .expect("expected 404"); +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 0969874fe7d..6448f71610c 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -8,6 +8,8 @@ use super::endpoints::*; use crate::integration_tests::saml::SAML_IDP_DESCRIPTOR; use crate::integration_tests::updates::TestTrustRoot; +use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::Utc; use dropshot::HttpErrorResponseBody; use dropshot::test_util::ClientTestContext; use headers::authorization::Credentials; @@ -141,6 +143,35 @@ async fn test_unauthorized() { .await .unwrap(); + // Insert a SCIM client bearer token with a known UUID - normally these are + // completely random. + + { + use nexus_db_model::ScimClientBearerToken; + use nexus_types::silo::DEFAULT_SILO_ID; + + let now = Utc::now(); + + let new_token = ScimClientBearerToken { + id: "7885144e-9c75-47f7-a97d-7dfc58e1186c".parse().unwrap(), + time_created: now, + time_deleted: None, + time_expires: Some(now), + silo_id: DEFAULT_SILO_ID, + bearer_token: String::from("testpost"), + }; + + let nexus = &cptestctx.server.server_context().nexus; + let conn = nexus.datastore().pool_connection_for_tests().await.unwrap(); + + use nexus_db_schema::schema::scim_client_bearer_token::dsl; + diesel::insert_into(dsl::scim_client_bearer_token) + .values(new_token.clone()) + .execute_async(&*conn) + .await + .unwrap(); + } + // Verify the hardcoded endpoints. info!(log, "verifying endpoints"); print!("{}", VERIFY_HEADER); diff --git a/nexus/tests/integration_tests/users_builtin.rs b/nexus/tests/integration_tests/users_builtin.rs index 3c5337b5584..23a1858593e 100644 --- a/nexus/tests/integration_tests/users_builtin.rs +++ b/nexus/tests/integration_tests/users_builtin.rs @@ -54,9 +54,6 @@ async fn test_users_builtin(cptestctx: &ControlPlaneTestContext) { let u = users.remove(&authn::USER_SAGA_RECOVERY.name.to_string()).unwrap(); assert_eq!(u.identity.id, authn::USER_SAGA_RECOVERY.id.into_untyped_uuid()); - let u = users.remove(&authn::USER_EXTERNAL_SCIM.name.to_string()).unwrap(); - assert_eq!(u.identity.id, authn::USER_EXTERNAL_SCIM.id.into_untyped_uuid()); - assert!(users.is_empty(), "found unexpected built-in users"); // TODO-coverage add test for fetching individual users, including invalid diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 4b620abd0c2..5bfc5137529 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,8 +1,6 @@ API endpoints with no coverage in authz tests: probe_delete (delete "/experimental/v1/probes/{probe}") current_user_access_token_delete (delete "/v1/me/access-tokens/{token_id}") -scim_token_delete_all (delete "/v1/system/scim/tokens") -scim_token_delete (delete "/v1/system/scim/tokens/{token_id}") probe_list (get "/experimental/v1/probes") probe_view (get "/experimental/v1/probes/{probe}") support_bundle_download (get "/experimental/v1/system/support-bundles/{bundle_id}/download") @@ -12,8 +10,6 @@ ping (get "/v1/ping") networking_switch_port_lldp_neighbors (get "/v1/system/hardware/rack-switch-port/{rack_id}/{switch_location}/{port}/lldp/neighbors") networking_switch_port_lldp_config_view (get "/v1/system/hardware/switch-port/{port}/lldp/config") networking_switch_port_status (get "/v1/system/hardware/switch-port/{port}/status") -scim_token_list (get "/v1/system/scim/tokens") -scim_token_view (get "/v1/system/scim/tokens/{token_id}") support_bundle_head (head "/experimental/v1/system/support-bundles/{bundle_id}/download") support_bundle_head_file (head "/experimental/v1/system/support-bundles/{bundle_id}/download/{file}") device_auth_request (post "/device/auth") @@ -25,4 +21,3 @@ alert_delivery_resend (post "/v1/alerts/{alert_id}/resend") login_local (post "/v1/login/{silo_name}/local") logout (post "/v1/logout") networking_switch_port_lldp_config_update (post "/v1/system/hardware/switch-port/{port}/lldp/config") -scim_token_create (post "/v1/system/scim/tokens") diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 397265204da..fb1c5ac1402 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -1728,6 +1728,10 @@ pub enum AuditLogEntryActor { silo_id: Uuid, }, + Scim { + silo_id: Uuid, + }, + Unauthenticated, } diff --git a/openapi/nexus.json b/openapi/nexus.json index 6a028c417c3..e7b3662de05 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -10431,36 +10431,6 @@ "$ref": "#/components/responses/Error" } } - }, - "delete": { - "tags": [ - "system/silos" - ], - "summary": "Delete all SCIM tokens", - "description": "Specify the silo by name or ID using the `silo` query parameter.", - "operationId": "scim_token_delete_all", - "parameters": [ - { - "in": "query", - "name": "silo", - "description": "Name or ID of the silo", - "required": true, - "schema": { - "$ref": "#/components/schemas/NameOrId" - } - } - ], - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } } }, "/v1/system/scim/tokens/{token_id}": { diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index c927c560c17..d23ee08aea8 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -6016,7 +6016,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_record_per_volume_resource_usage on omicro CREATE TYPE IF NOT EXISTS omicron.public.audit_log_actor_kind AS ENUM ( 'user_builtin', 'silo_user', - 'unauthenticated' + 'unauthenticated', + 'scim' ); CREATE TYPE IF NOT EXISTS omicron.public.audit_log_result_kind AS ENUM ( @@ -6783,6 +6784,33 @@ CREATE UNIQUE INDEX IF NOT EXISTS lookup_db_metadata_nexus_by_state on omicron.p nexus_id ); +CREATE TABLE IF NOT EXISTS omicron.public.scim_client_bearer_token ( + /* Identity metadata */ + id UUID PRIMARY KEY, + + time_created TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + time_expires TIMESTAMPTZ, + + silo_id UUID NOT NULL, + + bearer_token TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS + lookup_scim_client_by_silo_id +ON + omicron.public.scim_client_bearer_token (silo_id, id) +WHERE + time_deleted IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS + bearer_token_unique_for_scim_client +ON + omicron.public.scim_client_bearer_token (bearer_token) +WHERE + time_deleted IS NULL; + -- Keep this at the end of file so that the database does not contain a version -- until it is fully populated. INSERT INTO omicron.public.db_metadata ( @@ -6792,7 +6820,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '199.0.0', NULL) + (TRUE, NOW(), NOW(), '200.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/crdb/scim-client-bearer-token/up01.sql b/schema/crdb/scim-client-bearer-token/up01.sql new file mode 100644 index 00000000000..6715f9a340f --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up01.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS omicron.public.scim_client_bearer_token ( + /* Identity metadata */ + id UUID PRIMARY KEY, + time_created TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + time_expires TIMESTAMPTZ, + silo_id UUID NOT NULL, + bearer_token TEXT NOT NULL +); diff --git a/schema/crdb/scim-client-bearer-token/up02.sql b/schema/crdb/scim-client-bearer-token/up02.sql new file mode 100644 index 00000000000..4ba5ddd075d --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up02.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS + lookup_scim_client_by_silo_id +ON + omicron.public.scim_client_bearer_token (silo_id, id) +WHERE + time_deleted IS NULL; diff --git a/schema/crdb/scim-client-bearer-token/up03.sql b/schema/crdb/scim-client-bearer-token/up03.sql new file mode 100644 index 00000000000..3ae034d15f4 --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up03.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS + bearer_token_unique_for_scim_client +ON + omicron.public.scim_client_bearer_token (bearer_token) +WHERE + time_deleted IS NULL; diff --git a/schema/crdb/scim-client-bearer-token/up04.sql b/schema/crdb/scim-client-bearer-token/up04.sql new file mode 100644 index 00000000000..58d217491b6 --- /dev/null +++ b/schema/crdb/scim-client-bearer-token/up04.sql @@ -0,0 +1,6 @@ +ALTER TYPE + omicron.public.audit_log_actor_kind +ADD VALUE IF NOT EXISTS + 'scim' +AFTER + 'unauthenticated'; diff --git a/smf/nexus/multi-sled/config-partial.toml b/smf/nexus/multi-sled/config-partial.toml index 5f835b712fa..14b0281cc24 100644 --- a/smf/nexus/multi-sled/config-partial.toml +++ b/smf/nexus/multi-sled/config-partial.toml @@ -9,7 +9,7 @@ session_idle_timeout_minutes = 480 # 8 hours session_absolute_timeout_minutes = 1440 # 24 hours [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe diff --git a/smf/nexus/single-sled/config-partial.toml b/smf/nexus/single-sled/config-partial.toml index e72748febb0..32e20ee79f0 100644 --- a/smf/nexus/single-sled/config-partial.toml +++ b/smf/nexus/single-sled/config-partial.toml @@ -9,7 +9,7 @@ session_idle_timeout_minutes = 480 # 8 hours session_absolute_timeout_minutes = 1440 # 24 hours [authn] -schemes_external = ["session_cookie", "access_token"] +schemes_external = ["session_cookie", "access_token", "scim_token"] [log] # Show log messages of this level and more severe