diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f3834f..b079e4e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. - Use `--file-log-rotation-period` (or `FILE_LOG_ROTATION_PERIOD`) to configure the frequency of rotation. - Use `--console-log-format` (or `CONSOLE_LOG_FORMAT`) to set the format to `plain` (default) or `json`. - Log the startup event for bundle-builder and user-info-fetcher ([#703]). +- Support experimental user-info-fetcher Entra backend to fetch user groups ([#712]). ### Changed @@ -42,6 +43,7 @@ All notable changes to this project will be documented in this file. [#707]: https://github.com/stackabletech/opa-operator/pull/707 [#709]: https://github.com/stackabletech/opa-operator/pull/709 [#710]: https://github.com/stackabletech/opa-operator/pull/710 +[#712]: https://github.com/stackabletech/opa-operator/pull/712 [#715]: https://github.com/stackabletech/opa-operator/pull/715 ## [25.3.0] - 2025-03-21 diff --git a/deploy/helm/opa-operator/crds/crds.yaml b/deploy/helm/opa-operator/crds/crds.yaml index 95b0f804..bfe07e38 100644 --- a/deploy/helm/opa-operator/crds/crds.yaml +++ b/deploy/helm/opa-operator/crds/crds.yaml @@ -64,6 +64,8 @@ spec: - experimentalXfscAas - required: - experimentalActiveDirectory + - required: + - experimentalEntra properties: experimentalActiveDirectory: description: Backend that fetches user information from Active Directory @@ -137,6 +139,81 @@ spec: - kerberosSecretClassName - ldapServer type: object + experimentalEntra: + description: Backend that fetches user information from Microsoft Entra + properties: + clientCredentialsSecret: + description: |- + Name of a Secret that contains client credentials of an Entra account with permissions `User.ReadAll` and `GroupMemberShip.ReadAll`. + + Must contain the fields `clientId` and `clientSecret`. + type: string + port: + description: Port of the identity provider. If TLS is used defaults to `443`, otherwise to `80`. + format: uint16 + minimum: 0.0 + nullable: true + type: integer + tenantId: + description: The Microsoft Entra tenant ID. + type: string + tls: + default: + verification: + server: + caCert: + webPki: {} + description: Use a TLS connection. Should usually be set to WebPki. + nullable: true + properties: + verification: + description: The verification method used to verify the certificates of the server and/or the client. + oneOf: + - required: + - none + - required: + - server + properties: + none: + description: Use TLS but don't verify certificates. + type: object + server: + description: Use TLS and a CA certificate to verify the server. + properties: + caCert: + description: CA cert to verify the server. + oneOf: + - required: + - webPki + - required: + - secretClass + properties: + secretClass: + description: Name of the [SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) which will provide the CA certificate. Note that a SecretClass does not need to have a key but can also work with just a CA certificate, so if you got provided with a CA cert but don't have access to the key you can still use this method. + type: string + webPki: + description: Use TLS and the CA certificates trusted by the common web browsers to verify the server. This can be useful when you e.g. use public AWS S3 or other public available services. + type: object + type: object + required: + - caCert + type: object + type: object + required: + - verification + type: object + tokenHostname: + default: login.microsoft.com + description: Hostname of the token provider, defaults to `login.microsoft.com`. + type: string + userInfoHostname: + default: graph.microsoft.com + description: Hostname of the user info provider, defaults to `graph.microsoft.com`. + type: string + required: + - clientCredentialsSecret + - tenantId + type: object experimentalXfscAas: description: Backend that fetches user information from the Gaia-X Cross Federation Services Components (XFSC) Authentication & Authorization Service. properties: diff --git a/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc b/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc index 101b722f..1fb8cd04 100644 --- a/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc +++ b/docs/modules/opa/pages/usage-guide/user-info-fetcher.adoc @@ -55,6 +55,7 @@ Currently the following backends are supported: * xref:#backend-keycloak[] * xref:#backend-activedirectory[] +* xref:#backend-entra[] [#backends] == Backends @@ -109,6 +110,29 @@ spec: <7> The name of the SecretClass that knows how to create Kerberos keytabs trusted by Active Directory <8> The name of the SecretClass that contains the Active Directory's root CA certificate(s) +[#backend-entra] +=== Entra + +WARNING: The Entra backend is experimental, and subject to change. + +Fetch groups but not roles for a user from Entra. + +NOTE: The client in Entra must use the `client_credentials` flow and requires the `User.ReadAll` and `GroupMemberShip.ReadAll` permissions. + +[source,yaml] +---- +spec: + clusterConfig: + userInfo: + backend: + experimentalEntra: # <1> + tenantId: 00000000-0000-0000-0000-000000000000 # <2> + clientCredentialsSecret: user-info-fetcher-client-credentials # <3> +---- +<1> Enables the Entra backend +<2> The Entra tenant ID +<3> A secret containing the `clientId` and `clientSecret` keys + == User info fetcher API User information can be retrieved from regorules using the functions `userInfoByUsername(username)` and `userInfoById(id)` in `data.stackable.opa.userinfo.v1`. diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index 415cdc66..e5723ce3 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -31,7 +31,7 @@ use stackable_operator::{ product_image_selection::ResolvedProductImage, rbac::build_rbac_resources, secret_class::{SecretClassVolume, SecretClassVolumeScope}, - tls_verification::TlsClientDetailsError, + tls_verification::{TlsClientDetails, TlsClientDetailsError}, }, k8s_openapi::{ DeepMerge, @@ -1014,6 +1014,29 @@ fn build_server_rolegroup_daemonset( .add_volumes_and_mounts(&mut pb, vec![&mut cb_user_info_fetcher]) .context(UserInfoFetcherTlsVolumeAndMountsSnafu)?; } + user_info_fetcher::v1alpha1::Backend::Entra(entra) => { + pb.add_volume( + VolumeBuilder::new(USER_INFO_FETCHER_CREDENTIALS_VOLUME_NAME) + .secret(SecretVolumeSource { + secret_name: Some(entra.client_credentials_secret.clone()), + ..Default::default() + }) + .build(), + ) + .context(AddVolumeSnafu)?; + cb_user_info_fetcher + .add_volume_mount( + USER_INFO_FETCHER_CREDENTIALS_VOLUME_NAME, + USER_INFO_FETCHER_CREDENTIALS_DIR, + ) + .context(AddVolumeMountSnafu)?; + + TlsClientDetails { + tls: entra.tls.clone(), + } + .add_volumes_and_mounts(&mut pb, vec![&mut cb_user_info_fetcher]) + .context(UserInfoFetcherTlsVolumeAndMountsSnafu)?; + } } pb.add_container(cb_user_info_fetcher.build()); diff --git a/rust/operator-binary/src/crd/user_info_fetcher.rs b/rust/operator-binary/src/crd/user_info_fetcher.rs index 12ca9085..6bed814e 100644 --- a/rust/operator-binary/src/crd/user_info_fetcher.rs +++ b/rust/operator-binary/src/crd/user_info_fetcher.rs @@ -1,8 +1,11 @@ -use std::collections::BTreeMap; +use std::{collections::BTreeMap, str::FromStr}; use serde::{Deserialize, Serialize}; use stackable_operator::{ - commons::{networking::HostName, tls_verification::TlsClientDetails}, + commons::{ + networking::HostName, + tls_verification::{CaCert, Tls, TlsClientDetails, TlsServerVerification, TlsVerification}, + }, schemars::{self, JsonSchema}, time::Duration, versioned::versioned, @@ -38,6 +41,10 @@ pub mod versioned { /// Backend that fetches user information from Active Directory #[serde(rename = "experimentalActiveDirectory")] ActiveDirectory(v1alpha1::ActiveDirectoryBackend), + + /// Backend that fetches user information from Microsoft Entra + #[serde(rename = "experimentalEntra")] + Entra(v1alpha1::EntraBackend), } #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] @@ -110,6 +117,39 @@ pub mod versioned { pub additional_group_attribute_filters: BTreeMap, } + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct EntraBackend { + /// Hostname of the token provider, defaults to `login.microsoft.com`. + #[serde(default = "entra_default_token_hostname")] + pub token_hostname: HostName, + + /// Hostname of the user info provider, defaults to `graph.microsoft.com`. + #[serde(default = "entra_default_user_info_hostname")] + pub user_info_hostname: HostName, + + /// Port of the identity provider. If TLS is used defaults to `443`, otherwise to `80`. + pub port: Option, + + /// The Microsoft Entra tenant ID. + pub tenant_id: String, + + /// Use a TLS connection. Should usually be set to WebPki. + // We do not use the flattened `TlsClientDetails` here since we cannot + // default to WebPki using a default and flatten + // https://github.com/serde-rs/serde/issues/1626 + // This means we have to wrap `Tls` in `TlsClientDetails` to use its + // method like `uses_tls()`. + #[serde(default = "default_tls_web_pki")] + pub tls: Option, + + /// Name of a Secret that contains client credentials of an Entra account with + /// permissions `User.ReadAll` and `GroupMemberShip.ReadAll`. + /// + /// Must contain the fields `clientId` and `clientSecret`. + pub client_credentials_secret: String, + } + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Cache { @@ -129,6 +169,22 @@ fn default_root_path() -> String { "/".to_string() } +fn entra_default_token_hostname() -> HostName { + HostName::from_str("login.microsoft.com").unwrap() +} + +fn entra_default_user_info_hostname() -> HostName { + HostName::from_str("graph.microsoft.com").unwrap() +} + +fn default_tls_web_pki() -> Option { + Some(Tls { + verification: TlsVerification::Server(TlsServerVerification { + ca_cert: CaCert::WebPki {}, + }), + }) +} + fn aas_default_port() -> u16 { 5000 } diff --git a/rust/user-info-fetcher/src/backend/entra.rs b/rust/user-info-fetcher/src/backend/entra.rs new file mode 100644 index 00000000..f261df50 --- /dev/null +++ b/rust/user-info-fetcher/src/backend/entra.rs @@ -0,0 +1,286 @@ +use std::collections::HashMap; + +use hyper::StatusCode; +use serde::Deserialize; +use snafu::{ResultExt, Snafu}; +use stackable_opa_operator::crd::user_info_fetcher::v1alpha1; +use stackable_operator::commons::{networking::HostName, tls_verification::TlsClientDetails}; +use url::Url; + +use crate::{Credentials, UserInfo, UserInfoRequest, http_error, utils::http::send_json_request}; + +#[derive(Snafu, Debug)] +pub enum Error { + #[snafu(display("failed to get access_token"))] + AccessToken { source: crate::utils::http::Error }, + + #[snafu(display("failed to search for user with username {username:?}"))] + SearchForUser { + source: crate::utils::http::Error, + username: String, + }, + + #[snafu(display("failed to search for user with id {user_id:?}"))] + UserNotFoundById { + source: crate::utils::http::Error, + user_id: String, + }, + + #[snafu(display( + "failed to request groups for user with username {username:?} (user_id: {user_id:?})" + ))] + RequestUserGroups { + source: crate::utils::http::Error, + username: String, + user_id: String, + }, + + #[snafu(display("failed to to build entra endpoint for {endpoint}"))] + BuildEntraEndpointFailed { + source: url::ParseError, + endpoint: String, + }, +} + +impl http_error::Error for Error { + fn status_code(&self) -> StatusCode { + match self { + Self::AccessToken { .. } => StatusCode::BAD_GATEWAY, + Self::SearchForUser { .. } => StatusCode::BAD_GATEWAY, + Self::UserNotFoundById { .. } => StatusCode::NOT_FOUND, + Self::RequestUserGroups { .. } => StatusCode::BAD_GATEWAY, + Self::BuildEntraEndpointFailed { .. } => StatusCode::BAD_REQUEST, + } + } +} + +#[derive(Deserialize)] +struct OAuthResponse { + access_token: String, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UserMetadata { + id: String, + user_principal_name: String, + #[serde(default)] + attributes: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GroupMembershipResponse { + value: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GroupMembership { + display_name: Option, +} + +pub(crate) async fn get_user_info( + req: &UserInfoRequest, + http: &reqwest::Client, + credentials: &Credentials, + config: &v1alpha1::EntraBackend, +) -> Result { + let v1alpha1::EntraBackend { + client_credentials_secret: _, + token_hostname, + user_info_hostname, + port, + tenant_id, + tls, + } = config; + + let entra_backend = EntraBackend::try_new( + token_hostname, + user_info_hostname, + *port, + tenant_id, + TlsClientDetails { tls: tls.clone() }.uses_tls(), + )?; + + let token_url = entra_backend.oauth2_token(); + let authn = send_json_request::(http.post(token_url).form(&[ + ("client_id", credentials.client_id.as_str()), + ("client_secret", credentials.client_secret.as_str()), + ("scope", "https://graph.microsoft.com/.default"), + ("grant_type", "client_credentials"), + ])) + .await + .context(AccessTokenSnafu)?; + + let user_info = match req { + UserInfoRequest::UserInfoRequestById(req) => { + let user_id = &req.id; + send_json_request::( + http.get(entra_backend.user_info(user_id)) + .bearer_auth(&authn.access_token), + ) + .await + .with_context(|_| UserNotFoundByIdSnafu { + user_id: user_id.clone(), + })? + } + UserInfoRequest::UserInfoRequestByName(req) => { + let username = &req.username; + send_json_request::( + http.get(entra_backend.user_info(username)) + .bearer_auth(&authn.access_token), + ) + .await + .with_context(|_| SearchForUserSnafu { + username: username.clone(), + })? + } + }; + + let groups = send_json_request::( + http.get(entra_backend.group_info(&user_info.id)) + .bearer_auth(&authn.access_token), + ) + .await + .with_context(|_| RequestUserGroupsSnafu { + username: user_info.user_principal_name.clone(), + user_id: user_info.id.clone(), + })? + .value; + + Ok(UserInfo { + id: Some(user_info.id), + username: Some(user_info.user_principal_name), + groups: groups.into_iter().filter_map(|g| g.display_name).collect(), + custom_attributes: user_info.attributes, + }) +} + +struct EntraBackend { + token_endpoint_url: Url, + user_info_endpoint_url: Url, +} + +impl EntraBackend { + pub fn try_new( + token_endpoint: &HostName, + user_info_endpoint: &HostName, + port: Option, + tenant_id: &str, + uses_tls: bool, + ) -> Result { + let schema = if uses_tls { "https" } else { "http" }; + let port = port.unwrap_or(if uses_tls { 443 } else { 80 }); + + let token_endpoint = + format!("{schema}://{token_endpoint}:{port}/{tenant_id}/oauth2/v2.0/token"); + let token_endpoint_url = + Url::parse(&token_endpoint).context(BuildEntraEndpointFailedSnafu { + endpoint: token_endpoint, + })?; + + let user_info_endpoint = format!("{schema}://{user_info_endpoint}:{port}"); + let user_info_endpoint_url = + Url::parse(&user_info_endpoint).context(BuildEntraEndpointFailedSnafu { + endpoint: user_info_endpoint, + })?; + + Ok(Self { + token_endpoint_url, + user_info_endpoint_url, + }) + } + + pub fn oauth2_token(&self) -> Url { + self.token_endpoint_url.clone() + } + + // Works both with id/oid and userPrincipalName + pub fn user_info(&self, user: &str) -> Url { + let mut user_info_url = self.user_info_endpoint_url.clone(); + user_info_url.set_path(&format!("/v1.0/users/{user}")); + user_info_url + } + + pub fn group_info(&self, user: &str) -> Url { + let mut user_info_url = self.user_info_endpoint_url.clone(); + user_info_url.set_path(&format!("/v1.0/users/{user}/memberOf")); + user_info_url + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn test_entra_defaults_id() { + let tenant_id = "1234-5678-1234-5678"; + let user = "1234-5678-1234-5678"; + + let entra = EntraBackend::try_new( + &HostName::from_str("login.microsoft.com").unwrap(), + &HostName::from_str("graph.microsoft.com").unwrap(), + None, + tenant_id, + true, + ) + .unwrap(); + + assert_eq!( + entra.oauth2_token(), + Url::parse(&format!( + "https://login.microsoft.com/{tenant_id}/oauth2/v2.0/token" + )) + .unwrap() + ); + assert_eq!( + entra.user_info(user), + Url::parse(&format!("https://graph.microsoft.com/v1.0/users/{user}")).unwrap() + ); + assert_eq!( + entra.group_info(user), + Url::parse(&format!( + "https://graph.microsoft.com/v1.0/users/{user}/memberOf" + )) + .unwrap() + ); + } + + #[test] + fn test_entra_custom_id() { + let tenant_id = "1234-5678-1234-5678"; + let user = "1234-5678-1234-5678"; + + let entra = EntraBackend::try_new( + &HostName::from_str("login.mock.com").unwrap(), + &HostName::from_str("graph.mock.com").unwrap(), + Some(8080), + tenant_id, + false, + ) + .unwrap(); + + assert_eq!( + entra.oauth2_token(), + Url::parse(&format!( + "http://login.mock.com:8080/{tenant_id}/oauth2/v2.0/token" + )) + .unwrap() + ); + assert_eq!( + entra.user_info(user), + Url::parse(&format!("http://graph.mock.com:8080/v1.0/users/{user}")).unwrap() + ); + assert_eq!( + entra.group_info(user), + Url::parse(&format!( + "http://graph.mock.com:8080/v1.0/users/{user}/memberOf" + )) + .unwrap() + ); + } +} diff --git a/rust/user-info-fetcher/src/backend/mod.rs b/rust/user-info-fetcher/src/backend/mod.rs index 4540c6bf..c9a32709 100644 --- a/rust/user-info-fetcher/src/backend/mod.rs +++ b/rust/user-info-fetcher/src/backend/mod.rs @@ -1,3 +1,4 @@ pub mod active_directory; +pub mod entra; pub mod keycloak; pub mod xfsc_aas; diff --git a/rust/user-info-fetcher/src/main.rs b/rust/user-info-fetcher/src/main.rs index cdf56262..35fea4b6 100644 --- a/rust/user-info-fetcher/src/main.rs +++ b/rust/user-info-fetcher/src/main.rs @@ -13,7 +13,7 @@ use reqwest::ClientBuilder; use serde::{Deserialize, Serialize}; use snafu::{ResultExt, Snafu}; use stackable_opa_operator::crd::user_info_fetcher::v1alpha1; -use stackable_operator::telemetry::Tracing; +use stackable_operator::{commons::tls_verification::TlsClientDetails, telemetry::Tracing}; use tokio::net::TcpListener; mod backend; @@ -144,6 +144,10 @@ async fn main() -> Result<(), StartupError> { client_id: "".to_string(), client_secret: "".to_string(), }, + v1alpha1::Backend::Entra(_) => Credentials { + client_id: read_config_file(&args.credentials_dir.join("clientId")).await?, + client_secret: read_config_file(&args.credentials_dir.join("clientSecret")).await?, + }, }); let mut client_builder = ClientBuilder::new(); @@ -156,6 +160,15 @@ async fn main() -> Result<(), StartupError> { client_builder = utils::tls::configure_reqwest(&keycloak.tls, client_builder) .await .context(ConfigureTlsSnafu)?; + } else if let v1alpha1::Backend::Entra(entra) = &config.backend { + client_builder = utils::tls::configure_reqwest( + &TlsClientDetails { + tls: entra.tls.clone(), + }, + client_builder, + ) + .await + .context(ConfigureTlsSnafu)?; } let http = client_builder.build().context(ConstructHttpClientSnafu)?; @@ -253,6 +266,9 @@ enum GetUserInfoError { ActiveDirectory { source: backend::active_directory::Error, }, + + #[snafu(display("failed to get user information from Entra"))] + Entra { source: backend::entra::Error }, } impl http_error::Error for GetUserInfoError { @@ -267,6 +283,7 @@ impl http_error::Error for GetUserInfoError { Self::Keycloak { source } => source.status_code(), Self::ExperimentalXfscAas { source } => source.status_code(), Self::ActiveDirectory { source } => source.status_code(), + Self::Entra { source } => source.status_code(), } } } @@ -327,6 +344,11 @@ async fn get_user_info( .await .context(get_user_info_error::ActiveDirectorySnafu) } + v1alpha1::Backend::Entra(entra) => { + backend::entra::get_user_info(&req, &http, &credentials, entra) + .await + .context(get_user_info_error::EntraSnafu) + } } }) .await?,