From 95fc6fc5bf7034b6203744173916d6a3948a7d13 Mon Sep 17 00:00:00 2001 From: Firstyear Date: Thu, 5 Sep 2024 14:19:27 +1000 Subject: [PATCH] 20240828 Support Larger Images, Allow Custom Domain Icons (#3016) Allow setting custom domain icons. --- libs/client/src/domain.rs | 68 +++++++++ libs/client/src/lib.rs | 1 + server/core/src/actors/v1_read.rs | 32 ++-- server/core/src/actors/v1_write.rs | 66 +++------ server/core/src/https/apidocs/mod.rs | 3 + server/core/src/https/extractors/mod.rs | 18 +++ server/core/src/https/manifest.rs | 18 +-- server/core/src/https/middleware/caching.rs | 2 +- server/core/src/https/mod.rs | 15 +- server/core/src/https/oauth2.rs | 16 +- server/core/src/https/ui.rs | 27 ++-- server/core/src/https/v1.rs | 8 +- server/core/src/https/v1_domain.rs | 140 ++++++++++++++++++ server/core/src/https/v1_oauth2.rs | 6 +- server/core/src/https/views/login.rs | 68 +++++++-- server/core/src/https/views/profile.rs | 13 +- server/core/src/https/views/reset.rs | 34 ++--- server/core/src/lib.rs | 2 +- server/core/templates/credentials_reset.html | 4 +- .../templates/credentials_reset_form.html | 2 +- server/core/templates/login_base.html | 4 + server/lib/src/constants/acp.rs | 55 +++++++ server/lib/src/constants/schema.rs | 23 +++ server/lib/src/idm/server.rs | 12 ++ server/lib/src/plugins/protected.rs | 1 + server/lib/src/server/migrations.rs | 2 + server/lib/src/server/mod.rs | 91 +++++++----- server/lib/src/valueset/image/mod.rs | 3 +- tools/cli/src/cli/domain/mod.rs | 57 +++++++ tools/cli/src/opt/kanidm.rs | 16 ++ 30 files changed, 611 insertions(+), 196 deletions(-) create mode 100644 libs/client/src/domain.rs create mode 100644 server/core/src/https/v1_domain.rs diff --git a/libs/client/src/domain.rs b/libs/client/src/domain.rs new file mode 100644 index 000000000..50457e647 --- /dev/null +++ b/libs/client/src/domain.rs @@ -0,0 +1,68 @@ +use crate::{ClientError, KanidmClient}; +use kanidm_proto::internal::ImageValue; +use reqwest::multipart; + +impl KanidmClient { + /// Clear the current domain logo/image + pub async fn idm_domain_delete_image(&self) -> Result<(), ClientError> { + self.perform_delete_request("/v1/domain/_image").await + } + + /// Add or update the domain logo/image + pub async fn idm_domain_update_image(&self, image: ImageValue) -> Result<(), ClientError> { + let file_content_type = image.filetype.as_content_type_str(); + + let file_data = match multipart::Part::bytes(image.contents.clone()) + .file_name(image.filename) + .mime_str(file_content_type) + { + Ok(part) => part, + Err(err) => { + error!( + "Failed to generate multipart body from image data: {:}", + err + ); + return Err(ClientError::SystemError); + } + }; + + let form = multipart::Form::new().part("image", file_data); + + // send it + let response = self + .client + .post(self.make_url("/v1/domain/_image")) + .multipart(form); + + let response = { + let tguard = self.bearer_token.read().await; + if let Some(token) = &(*tguard) { + response.bearer_auth(token) + } else { + response + } + }; + let response = response + .send() + .await + .map_err(|err| self.handle_response_error(err))?; + self.expect_version(&response).await; + + let opid = self.get_kopid_from_response(&response); + + match response.status() { + reqwest::StatusCode::OK => {} + unexpect => { + return Err(ClientError::Http( + unexpect, + response.json().await.ok(), + opid, + )) + } + } + response + .json() + .await + .map_err(|e| ClientError::JsonDecode(e, opid)) + } +} diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 56e36b8c5..16a82ecc2 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -48,6 +48,7 @@ use webauthn_rs_proto::{ PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, }; +mod domain; mod group; mod oauth; mod person; diff --git a/server/core/src/actors/v1_read.rs b/server/core/src/actors/v1_read.rs index 74b20a657..a326a7d23 100644 --- a/server/core/src/actors/v1_read.rs +++ b/server/core/src/actors/v1_read.rs @@ -40,7 +40,7 @@ use kanidmd_lib::{ AuthoriseResponse, JwkKeySet, Oauth2Error, Oauth2Rfc8414MetadataResponse, OidcDiscoveryResponse, OidcToken, }, - idm::server::IdmServerTransaction, + idm::server::{DomainInfoRead, IdmServerTransaction}, idm::serviceaccount::ListApiTokenEvent, idm::ClientAuthInfo, }; @@ -389,7 +389,7 @@ impl QueryServerReadV1 { &self, client_auth_info: ClientAuthInfo, rs: Filter, - ) -> Result { + ) -> Result, OperationError> { let mut idms_prox_read = self.idms.proxy_read().await?; let ct = duration_from_epoch_now(); @@ -409,17 +409,9 @@ impl QueryServerReadV1 { )?; let entries = idms_prox_read.qs_read.search(&search)?; - if entries.is_empty() { - return Err(OperationError::NoMatchingEntries); - } - let entry = match entries.first() { - Some(entry) => entry, - None => return Err(OperationError::NoMatchingEntries), - }; - match entry.get_ava_single_image(Attribute::Image) { - Some(image) => Ok(image), - None => Err(OperationError::NoMatchingEntries), - } + Ok(entries + .first() + .and_then(|entry| entry.get_ava_single_image(Attribute::Image))) } #[instrument( @@ -1554,16 +1546,6 @@ impl QueryServerReadV1 { idms_prox_read.list_applinks(&ident) } - #[instrument( - level = "info", - skip_all, - fields(uuid = ?eventid) - )] - pub async fn get_domain_display_name(&self, eventid: Uuid) -> Result { - let idms_prox_read = self.idms.proxy_read().await?; - Ok(idms_prox_read.qs_read.get_domain_display_name().to_string()) - } - #[instrument( level = "info", skip_all, @@ -1633,4 +1615,8 @@ impl QueryServerReadV1 { }; Some(res) } + + pub fn domain_info_read(&self) -> DomainInfoRead { + self.idms.domain_read() + } } diff --git a/server/core/src/actors/v1_write.rs b/server/core/src/actors/v1_write.rs index c8743e1ce..e291b0b17 100644 --- a/server/core/src/actors/v1_write.rs +++ b/server/core/src/actors/v1_write.rs @@ -1182,64 +1182,36 @@ impl QueryServerWriteV1 { } #[instrument(level = "debug", skip_all)] - pub async fn handle_oauth2_rs_image_delete( + pub async fn handle_image_update( &self, client_auth_info: ClientAuthInfo, - rs: Filter, + request_filter: Filter, + image: Option, ) -> Result<(), OperationError> { - let mut idms_prox_write = self.idms.proxy_write(duration_from_epoch_now()).await?; let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await?; let ident = idms_prox_write .validate_client_auth_info_to_ident(client_auth_info, ct) - .map_err(|e| { - admin_error!(err = ?e, "Invalid identity in handle_oauth2_rs_image_delete"); - e - })?; - let ml = ModifyList::new_purge(Attribute::Image); - let mdf = match ModifyEvent::from_internal_parts(ident, &ml, &rs, &idms_prox_write.qs_write) - { - Ok(m) => m, - Err(e) => { - admin_error!(err = ?e, "Failed to begin modify during handle_oauth2_rs_image_delete"); - return Err(e); - } - }; - idms_prox_write - .qs_write - .modify(&mdf) - .and_then(|_| idms_prox_write.commit().map(|_| ())) - } - - #[instrument(level = "debug", skip_all)] - pub async fn handle_oauth2_rs_image_update( - &self, - client_auth_info: ClientAuthInfo, - rs: Filter, - image: ImageValue, - ) -> Result<(), OperationError> { - let mut idms_prox_write = self.idms.proxy_write(duration_from_epoch_now()).await?; - let ct = duration_from_epoch_now(); - - let ident = idms_prox_write - .validate_client_auth_info_to_ident(client_auth_info, ct) - .map_err(|e| { - admin_error!(err = ?e, "Invalid identity in handle_oauth2_rs_image_update"); - e + .inspect_err(|err| { + error!(?err, "Invalid identity in handle_image_update"); })?; - let ml = ModifyList::new_purge_and_set(Attribute::Image, Value::Image(image)); - - let mdf = match ModifyEvent::from_internal_parts(ident, &ml, &rs, &idms_prox_write.qs_write) - { - Ok(m) => m, - Err(e) => { - admin_error!(err = ?e, "Failed to begin modify during handle_oauth2_rs_image_update"); - return Err(e); - } + let modlist = if let Some(image) = image { + ModifyList::new_purge_and_set(Attribute::Image, Value::Image(image)) + } else { + ModifyList::new_purge(Attribute::Image) }; - trace!(?mdf, "Begin modify event"); + let mdf = ModifyEvent::from_internal_parts( + ident, + &modlist, + &request_filter, + &idms_prox_write.qs_write, + ) + .inspect_err(|err| { + error!(?err, "Failed to begin modify during handle_image_update"); + })?; idms_prox_write .qs_write diff --git a/server/core/src/https/apidocs/mod.rs b/server/core/src/https/apidocs/mod.rs index 18a3b0503..199376851 100644 --- a/server/core/src/https/apidocs/mod.rs +++ b/server/core/src/https/apidocs/mod.rs @@ -163,6 +163,9 @@ impl Modify for SecurityAddon { super::v1::domain_attr_get, super::v1::domain_attr_put, super::v1::domain_attr_delete, + super::v1_domain::image_post, + super::v1_domain::image_delete, + super::v1::group_id_unix_token_get, super::v1::group_id_unix_post, super::v1::group_get, diff --git a/server/core/src/https/extractors/mod.rs b/server/core/src/https/extractors/mod.rs index 853173256..4d3fd686f 100644 --- a/server/core/src/https/extractors/mod.rs +++ b/server/core/src/https/extractors/mod.rs @@ -14,6 +14,8 @@ use axum_extra::extract::cookie::CookieJar; use kanidm_proto::constants::X_FORWARDED_FOR; use kanidm_proto::internal::COOKIE_BEARER_TOKEN; use kanidmd_lib::prelude::{ClientAuthInfo, ClientCertInfo, Source}; +// Re-export +pub use kanidmd_lib::idm::server::DomainInfoRead; use compact_jwt::JwsCompact; use std::str::FromStr; @@ -181,6 +183,22 @@ impl FromRequestParts for VerifiedClientInformation { } } +pub struct DomainInfo(pub DomainInfoRead); + +#[async_trait] +impl FromRequestParts for DomainInfo { + type Rejection = (StatusCode, &'static str); + + // Need to skip all to prevent leaking tokens to logs. + #[instrument(level = "debug", skip_all)] + async fn from_request_parts( + _parts: &mut Parts, + state: &ServerState, + ) -> Result { + Ok(DomainInfo(state.qe_r_ref.domain_info_read())) + } +} + #[derive(Debug, Clone)] pub struct ClientConnInfo { pub addr: SocketAddr, diff --git a/server/core/src/https/manifest.rs b/server/core/src/https/manifest.rs index 2a2d83d2d..6229ac844 100644 --- a/server/core/src/https/manifest.rs +++ b/server/core/src/https/manifest.rs @@ -1,16 +1,11 @@ //! Builds a Progressive Web App Manifest page. - -use axum::extract::State; use axum::http::header::CONTENT_TYPE; use axum::http::HeaderValue; use axum::response::{IntoResponse, Response}; -use axum::Extension; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; -use super::middleware::KOpId; -// Thanks to the webmanifest crate for a lot of this code -use super::ServerState; +use crate::https::extractors::DomainInfo; /// The MIME type for `.webmanifest` files. const MIME_TYPE_MANIFEST: &str = "application/manifest+json;charset=utf-8"; @@ -150,15 +145,8 @@ pub fn manifest_data(host_req: Option<&str>, domain_display_name: String) -> Man } /// Generates a manifest.json file for progressive web app usage -pub(crate) async fn manifest( - State(state): State, - Extension(kopid): Extension, -) -> impl IntoResponse { - let domain_display_name = state - .qe_r_ref - .get_domain_display_name(kopid.eventid) - .await - .unwrap_or_default(); +pub(crate) async fn manifest(DomainInfo(domain_info): DomainInfo) -> impl IntoResponse { + let domain_display_name = domain_info.display_name().to_string(); // TODO: fix the None here to make it the request host let manifest_string = match serde_json::to_string_pretty(&manifest_data(None, domain_display_name)) { diff --git a/server/core/src/https/middleware/caching.rs b/server/core/src/https/middleware/caching.rs index ee68e5450..c1718704b 100644 --- a/server/core/src/https/middleware/caching.rs +++ b/server/core/src/https/middleware/caching.rs @@ -20,7 +20,7 @@ pub async fn dont_cache_me(request: Request, next: Next) -> Response { } /// Adds a cache control header of 300 seconds to the response headers. -pub async fn cache_me(request: Request, next: Next) -> Response { +pub async fn cache_me_short(request: Request, next: Next) -> Response { let mut response = next.run(request).await; response.headers_mut().insert( header::CACHE_CONTROL, diff --git a/server/core/src/https/mod.rs b/server/core/src/https/mod.rs index c55e86d9e..048c6f06e 100644 --- a/server/core/src/https/mod.rs +++ b/server/core/src/https/mod.rs @@ -11,6 +11,7 @@ mod tests; pub(crate) mod trace; mod ui; mod v1; +mod v1_domain; mod v1_oauth2; mod v1_scim; mod views; @@ -270,12 +271,18 @@ pub async fn create_https_server( // Create a spa router that captures everything at ui without key extraction. if cfg!(feature = "ui_htmx") { Router::new() + .route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get)) + .route("/ui/images/domain", get(v1_domain::image_get)) + // Layers only apply to routes that are *already* added, not the ones + // added after. + .layer(middleware::compression::new()) + .layer(from_fn(middleware::caching::cache_me_short)) .route("/", get(|| async { Redirect::to("/ui") })) .nest("/ui", views::view_router()) - .layer(middleware::compression::new()) - .route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get)) } else { Router::new() + .route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get)) + .layer(middleware::compression::new()) // Direct users to the base app page. If a login is required, // then views will take care of redirection. .route("/", get(|| async { Redirect::temporary("/ui") })) @@ -288,8 +295,6 @@ pub async fn create_https_server( .nest("/ui/oauth2", ui::spa_router_login_flows()) // admin app .nest("/ui/admin", ui::spa_router_admin()) - .layer(middleware::compression::new()) - .route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get)) // skip_route_check } } @@ -329,7 +334,7 @@ pub async fn create_https_server( .nest_service("/pkg", ServeDir::new(pkg_path).precompressed_br()) .layer(middleware::compression::new()) } - .layer(from_fn(middleware::caching::cache_me)); + .layer(from_fn(middleware::caching::cache_me_short)); app.merge(pkg_router) } diff --git a/server/core/src/https/oauth2.rs b/server/core/src/https/oauth2.rs index 8585fd4fc..9746ac7c7 100644 --- a/server/core/src/https/oauth2.rs +++ b/server/core/src/https/oauth2.rs @@ -107,21 +107,21 @@ pub(crate) async fn oauth2_image_get( .await; match res { - Ok(image) => ( + Ok(Some(image)) => ( StatusCode::OK, [(CONTENT_TYPE, image.filetype.as_content_type_str())], image.contents, ) .into_response(), - Err(err) => { - admin_debug!( - "Unable to get image for oauth2 resource server {}: {:?}", - rs_name, - err - ); - // TODO: a 404 probably isn't perfect but it's not the worst + Ok(None) => { + warn!(?rs_name, "No image set for oauth2 client"); (StatusCode::NOT_FOUND, "").into_response() } + Err(err) => { + error!(?err, "Unable to get image for oauth2 client"); + // TODO: a 404 probably isn't perfect but it's not the worst + (StatusCode::INTERNAL_SERVER_ERROR, "").into_response() + } } } diff --git a/server/core/src/https/ui.rs b/server/core/src/https/ui.rs index d94377026..d16554b7c 100644 --- a/server/core/src/https/ui.rs +++ b/server/core/src/https/ui.rs @@ -3,11 +3,12 @@ use axum::http::header::CONTENT_TYPE; use axum::http::HeaderValue; use axum::response::Response; use axum::routing::get; -use axum::{Extension, Router}; +use axum::Router; -use super::middleware::KOpId; use super::ServerState; +use crate::https::extractors::{DomainInfo, DomainInfoRead}; + // when you want to put big text at the top of the page pub const CSS_PAGE_HEADER: &str = "d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-0 pb-0 mb-3 border-bottom"; @@ -40,36 +41,30 @@ pub(crate) fn spa_router_login_flows() -> Router { pub(crate) async fn ui_handler_user_ui( State(state): State, - Extension(kopid): Extension, + DomainInfo(domain_info): DomainInfo, ) -> Response { - ui_handler_generic(state, kopid, "wasmloader_user.js").await + ui_handler_generic(state, "wasmloader_user.js", domain_info).await } pub(crate) async fn ui_handler_admin( State(state): State, - Extension(kopid): Extension, + DomainInfo(domain_info): DomainInfo, ) -> Response { - ui_handler_generic(state, kopid, "wasmloader_admin.js").await + ui_handler_generic(state, "wasmloader_admin.js", domain_info).await } pub(crate) async fn ui_handler_login_flows( State(state): State, - Extension(kopid): Extension, + DomainInfo(domain_info): DomainInfo, ) -> Response { - ui_handler_generic(state, kopid, "wasmloader_login_flows.js").await + ui_handler_generic(state, "wasmloader_login_flows.js", domain_info).await } pub(crate) async fn ui_handler_generic( state: ServerState, - kopid: KOpId, wasmloader: &str, + domain_info: DomainInfoRead, ) -> Response { - let domain_display_name = state - .qe_r_ref - .get_domain_display_name(kopid.eventid) - .await - .unwrap_or_default(); - // let's get the tags we want to load the javascript files let mut jsfiles: Vec = state .js_files @@ -85,7 +80,7 @@ pub(crate) async fn ui_handler_generic( let body = format!( include_str!("ui_html.html"), - domain_display_name.as_str(), + domain_info.display_name(), jstags, ); diff --git a/server/core/src/https/v1.rs b/server/core/src/https/v1.rs index 8aba83bb2..f486379ab 100644 --- a/server/core/src/https/v1.rs +++ b/server/core/src/https/v1.rs @@ -29,7 +29,7 @@ use kanidmd_lib::prelude::*; use kanidmd_lib::value::PartialValue; use super::errors::WebError; -use super::middleware::caching::{cache_me, dont_cache_me}; +use super::middleware::caching::{cache_me_short, dont_cache_me}; use super::middleware::KOpId; use super::ServerState; use crate::https::apidocs::response_schema::{ApiResponseWithout200, DefaultApiResponse}; @@ -3090,7 +3090,7 @@ fn cacheable_routes(state: ServerState) -> Router { "/v1/account/:id/_radius/_token", get(account_id_radius_token_get), ) - .layer(from_fn(cache_me)) + .layer(from_fn(cache_me_short)) .with_state(state) } @@ -3347,6 +3347,10 @@ pub(crate) fn route_setup(state: ServerState) -> Router { .route("/v1/credential/_cancel", post(credential_update_cancel)) // domain-things .route("/v1/domain", get(domain_get)) + .route( + "/v1/domain/_image", + post(super::v1_domain::image_post).delete(super::v1_domain::image_delete), + ) .route( "/v1/domain/_attr/:attr", get(domain_attr_get) diff --git a/server/core/src/https/v1_domain.rs b/server/core/src/https/v1_domain.rs new file mode 100644 index 000000000..809fbe8db --- /dev/null +++ b/server/core/src/https/v1_domain.rs @@ -0,0 +1,140 @@ +use super::apidocs::response_schema::DefaultApiResponse; +use super::errors::WebError; +use super::ServerState; +use crate::https::extractors::DomainInfo; +use crate::https::extractors::VerifiedClientInformation; +use axum::extract::State; +use axum::Json; +use axum::{ + http::header::CONTENT_TYPE, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use kanidm_proto::internal::{ImageType, ImageValue}; +use kanidmd_lib::prelude::*; +use kanidmd_lib::valueset::image::ImageValueThings; +use sketching::admin_error; + +pub(crate) async fn image_get(DomainInfo(domain_info): DomainInfo) -> Response { + match domain_info.image() { + Some(image) => ( + StatusCode::OK, + [(CONTENT_TYPE, image.filetype.as_content_type_str())], + image.contents.clone(), + ) + .into_response(), + None => { + warn!("No image set for domain"); + (StatusCode::NOT_FOUND, "").into_response() + } + } +} + +#[utoipa::path( + delete, + path = "/v1/domain/_image", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/domain", + operation_id = "domain_image_delete" +)] +pub(crate) async fn image_delete( + State(state): State, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, +) -> Result, WebError> { + let f_uuid = filter_all!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_DOMAIN_INFO))); + + state + .qe_w_ref + .handle_image_update(client_auth_info, f_uuid, None) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + post, + path = "/v1/domain/_image", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/domain", + operation_id = "domain_image_post" +)] +/// API endpoint for creating/replacing the image associated with an OAuth2 Resource Server. +/// +/// It requires a multipart form with the image file, and the content type must be one of the +/// [VALID_IMAGE_UPLOAD_CONTENT_TYPES]. +pub(crate) async fn image_post( + State(state): State, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + mut multipart: axum::extract::Multipart, +) -> Result, WebError> { + // because we might not get an image + let mut image: Option = None; + + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let filename = field.file_name().map(|f| f.to_string()).clone(); + if let Some(filename) = filename { + let content_type = field.content_type().map(|f| f.to_string()).clone(); + + let content_type = match content_type { + Some(val) => { + if VALID_IMAGE_UPLOAD_CONTENT_TYPES.contains(&val.as_str()) { + val + } else { + debug!("Invalid content type: {}", val); + return Err(OperationError::InvalidRequestState.into()); + } + } + None => { + debug!("No content type header provided"); + return Err(OperationError::InvalidRequestState.into()); + } + }; + let data = match field.bytes().await { + Ok(val) => val, + Err(_e) => return Err(OperationError::InvalidRequestState.into()), + }; + + let filetype = match ImageType::try_from_content_type(&content_type) { + Ok(val) => val, + Err(_err) => return Err(OperationError::InvalidRequestState.into()), + }; + + image = Some(ImageValue { + filetype, + filename: filename.to_string(), + contents: data.to_vec(), + }); + }; + } + + match image { + Some(image) => { + let image_validation_result = image.validate_image(); + match image_validation_result { + Err(err) => { + admin_error!("Invalid image uploaded: {:?}", err); + Err(WebError::from(OperationError::InvalidRequestState)) + } + Ok(_) => { + let f_uuid = + filter_all!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_DOMAIN_INFO))); + state + .qe_w_ref + .handle_image_update(client_auth_info, f_uuid, Some(image)) + .await + .map(Json::from) + .map_err(WebError::from) + } + } + } + None => Err(WebError::from(OperationError::InvalidAttribute( + "No image included, did you mean to use the DELETE method?".to_string(), + ))), + } +} diff --git a/server/core/src/https/v1_oauth2.rs b/server/core/src/https/v1_oauth2.rs index 5e481afab..d3966a7ad 100644 --- a/server/core/src/https/v1_oauth2.rs +++ b/server/core/src/https/v1_oauth2.rs @@ -478,7 +478,7 @@ pub(crate) async fn oauth2_id_image_delete( ) -> Result, WebError> { state .qe_w_ref - .handle_oauth2_rs_image_delete(client_auth_info, oauth2_id(&rs_name)) + .handle_image_update(client_auth_info, oauth2_id(&rs_name), None) .await .map(Json::from) .map_err(WebError::from) @@ -553,10 +553,10 @@ pub(crate) async fn oauth2_id_image_post( Err(WebError::from(OperationError::InvalidRequestState)) } Ok(_) => { - let rs_name = oauth2_id(&rs_name); + let rs_filter = oauth2_id(&rs_name); state .qe_w_ref - .handle_oauth2_rs_image_update(client_auth_info, rs_name, image) + .handle_image_update(client_auth_info, rs_filter, Some(image)) .await .map(Json::from) .map_err(WebError::from) diff --git a/server/core/src/https/views/login.rs b/server/core/src/https/views/login.rs index 0e060c8d8..03037ff13 100644 --- a/server/core/src/https/views/login.rs +++ b/server/core/src/https/views/login.rs @@ -1,5 +1,9 @@ use super::{cookies, empty_string_as_none, HtmlTemplate, UnrecoverableErrorView}; -use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; +use crate::https::{ + extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation}, + middleware::KOpId, + ServerState, +}; use askama::Template; use axum::{ extract::State, @@ -43,6 +47,7 @@ struct SessionContext { #[derive(Template)] #[template(path = "login.html")] struct LoginView { + domain_custom_image: bool, username: String, remember_me: bool, } @@ -55,6 +60,7 @@ pub struct Mech<'a> { #[derive(Template)] #[template(path = "login_mech_choose.html")] struct LoginMechView<'a> { + domain_custom_image: bool, mechs: Vec>, } @@ -68,6 +74,7 @@ enum LoginTotpError { #[derive(Template, Default)] #[template(path = "login_totp.html")] struct LoginTotpView { + domain_custom_image: bool, totp: String, errors: LoginTotpError, } @@ -75,16 +82,20 @@ struct LoginTotpView { #[derive(Template)] #[template(path = "login_password.html")] struct LoginPasswordView { + domain_custom_image: bool, password: String, } #[derive(Template)] #[template(path = "login_backupcode.html")] -struct LoginBackupCodeView {} +struct LoginBackupCodeView { + domain_custom_image: bool, +} #[derive(Template)] #[template(path = "login_webauthn.html")] struct LoginWebauthnView { + domain_custom_image: bool, // Control if we are rendering in security key or passkey mode. passkey: bool, // chal: RequestChallengeResponse, @@ -94,6 +105,7 @@ struct LoginWebauthnView { #[derive(Template, Default)] #[template(path = "login_denied.html")] struct LoginDeniedView { + domain_custom_image: bool, reason: String, operation_id: Uuid, } @@ -129,6 +141,7 @@ pub async fn view_reauth_get( kopid: KOpId, jar: CookieJar, return_location: &str, + domain_info: DomainInfoRead, ) -> axum::response::Result { let session_valid_result = state .qe_r_ref @@ -165,6 +178,7 @@ pub async fn view_reauth_get( ar, client_auth_info, session_context, + domain_info, ) .await { @@ -194,7 +208,10 @@ pub async fn view_reauth_get( let remember_me = !username.is_empty(); + let domain_custom_image = domain_info.image().is_some(); + HtmlTemplate(LoginView { + domain_custom_image, username, remember_me, }) @@ -213,6 +230,7 @@ pub async fn view_reauth_get( pub async fn view_index_get( State(state): State, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, Extension(kopid): Extension, jar: CookieJar, ) -> Response { @@ -236,7 +254,10 @@ pub async fn view_index_get( let remember_me = !username.is_empty(); + let domain_custom_image = domain_info.image().is_some(); + HtmlTemplate(LoginView { + domain_custom_image, username, remember_me, }) @@ -265,6 +286,7 @@ pub async fn view_login_begin_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, jar: CookieJar, Form(login_begin_form): Form, ) -> Response { @@ -315,6 +337,7 @@ pub async fn view_login_begin_post( ar, client_auth_info, session_context, + domain_info, ) .await { @@ -345,6 +368,7 @@ pub async fn view_login_mech_choose_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, jar: CookieJar, Form(login_mech_form): Form, ) -> Response { @@ -378,6 +402,7 @@ pub async fn view_login_mech_choose_post( ar, client_auth_info, session_context, + domain_info, ) .await { @@ -408,13 +433,16 @@ pub async fn view_login_totp_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, jar: CookieJar, Form(login_totp_form): Form, ) -> Response { // trim leading and trailing white space. let Ok(totp) = u32::from_str(login_totp_form.totp.trim()) else { + let domain_custom_image = domain_info.image().is_some(); // If not an int, we need to re-render with an error return HtmlTemplate(LoginTotpView { + domain_custom_image, totp: String::default(), errors: LoginTotpError::Syntax, }) @@ -422,7 +450,7 @@ pub async fn view_login_totp_post( }; let auth_cred = AuthCredential::Totp(totp); - credential_step(state, kopid, jar, client_auth_info, auth_cred).await + credential_step(state, kopid, jar, client_auth_info, auth_cred, domain_info).await } #[derive(Debug, Clone, Deserialize)] @@ -434,11 +462,12 @@ pub async fn view_login_pw_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, jar: CookieJar, Form(login_pw_form): Form, ) -> Response { let auth_cred = AuthCredential::Password(login_pw_form.password); - credential_step(state, kopid, jar, client_auth_info, auth_cred).await + credential_step(state, kopid, jar, client_auth_info, auth_cred, domain_info).await } #[derive(Debug, Clone, Deserialize)] @@ -450,35 +479,38 @@ pub async fn view_login_backupcode_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, jar: CookieJar, Form(login_bc_form): Form, ) -> Response { // People (like me) may copy-paste the bc with whitespace that causes issues. Trim it now. let trimmed = login_bc_form.backupcode.trim().to_string(); let auth_cred = AuthCredential::BackupCode(trimmed); - credential_step(state, kopid, jar, client_auth_info, auth_cred).await + credential_step(state, kopid, jar, client_auth_info, auth_cred, domain_info).await } pub async fn view_login_passkey_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, jar: CookieJar, Json(assertion): Json>, ) -> Response { let auth_cred = AuthCredential::Passkey(assertion); - credential_step(state, kopid, jar, client_auth_info, auth_cred).await + credential_step(state, kopid, jar, client_auth_info, auth_cred, domain_info).await } pub async fn view_login_seckey_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, jar: CookieJar, Json(assertion): Json>, ) -> Response { let auth_cred = AuthCredential::SecurityKey(assertion); - credential_step(state, kopid, jar, client_auth_info, auth_cred).await + credential_step(state, kopid, jar, client_auth_info, auth_cred, domain_info).await } async fn credential_step( @@ -487,6 +519,7 @@ async fn credential_step( jar: CookieJar, client_auth_info: ClientAuthInfo, auth_cred: AuthCredential, + domain_info: DomainInfoRead, ) -> Response { let session_context = cookies::get_signed::(&state, &jar, COOKIE_AUTH_SESSION_ID) @@ -514,6 +547,7 @@ async fn credential_step( ar, client_auth_info, session_context, + domain_info, ) .await { @@ -542,6 +576,7 @@ async fn view_login_step( auth_result: AuthResult, client_auth_info: ClientAuthInfo, mut session_context: SessionContext, + domain_info: DomainInfoRead, ) -> Result { trace!(?auth_result); @@ -551,6 +586,8 @@ async fn view_login_step( } = auth_result; session_context.id = Some(sessionid); + let domain_custom_image = domain_info.image().is_some(); + let mut safety = 3; // Unlike the api version, only set the cookie. @@ -610,7 +647,11 @@ async fn view_login_step( name: m, }) .collect(); - HtmlTemplate(LoginMechView { mechs }).into_response() + HtmlTemplate(LoginMechView { + domain_custom_image, + mechs, + }) + .into_response() } }; // break acts as return in a loop. @@ -642,16 +683,19 @@ async fn view_login_step( }) .into_response(), AuthAllowed::Password => HtmlTemplate(LoginPasswordView { + domain_custom_image, password: session_context.password.clone().unwrap_or_default(), }) .into_response(), - AuthAllowed::BackupCode => { - HtmlTemplate(LoginBackupCodeView {}).into_response() - } + AuthAllowed::BackupCode => HtmlTemplate(LoginBackupCodeView { + domain_custom_image, + }) + .into_response(), AuthAllowed::SecurityKey(chal) => { let chal_json = serde_json::to_string(&chal) .map_err(|_| OperationError::SerdeJsonError)?; HtmlTemplate(LoginWebauthnView { + domain_custom_image, passkey: false, chal: chal_json, }) @@ -661,6 +705,7 @@ async fn view_login_step( let chal_json = serde_json::to_string(&chal) .map_err(|_| OperationError::SerdeJsonError)?; HtmlTemplate(LoginWebauthnView { + domain_custom_image, passkey: true, chal: chal_json, }) @@ -738,6 +783,7 @@ async fn view_login_step( jar = jar.remove(Cookie::from(COOKIE_AUTH_SESSION_ID)); break HtmlTemplate(LoginDeniedView { + domain_custom_image, reason, operation_id: kopid.eventid, }) diff --git a/server/core/src/https/views/profile.rs b/server/core/src/https/views/profile.rs index 7e018511e..1c201d275 100644 --- a/server/core/src/https/views/profile.rs +++ b/server/core/src/https/views/profile.rs @@ -1,4 +1,4 @@ -use crate::https::extractors::VerifiedClientInformation; +use crate::https::extractors::{DomainInfo, VerifiedClientInformation}; use crate::https::middleware::KOpId; use crate::https::views::errors::HtmxError; use crate::https::views::HtmlTemplate; @@ -73,8 +73,17 @@ pub(crate) async fn view_profile_get( pub(crate) async fn view_profile_unlock_get( State(state): State, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, Extension(kopid): Extension, jar: CookieJar, ) -> axum::response::Result { - super::login::view_reauth_get(state, client_auth_info, kopid, jar, "/ui/profile").await + super::login::view_reauth_get( + state, + client_auth_info, + kopid, + jar, + "/ui/profile", + domain_info, + ) + .await } diff --git a/server/core/src/https/views/reset.rs b/server/core/src/https/views/reset.rs index 5a864de50..cbbba56af 100644 --- a/server/core/src/https/views/reset.rs +++ b/server/core/src/https/views/reset.rs @@ -24,7 +24,7 @@ use kanidm_proto::internal::{ UserAuthToken, COOKIE_CU_SESSION_TOKEN, }; -use crate::https::extractors::VerifiedClientInformation; +use crate::https::extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation}; use crate::https::middleware::KOpId; use crate::https::views::errors::HtmxError; use crate::https::ServerState; @@ -34,14 +34,14 @@ use super::{HtmlTemplate, UnrecoverableErrorView}; #[derive(Template)] #[template(path = "credentials_reset_form.html")] struct ResetCredFormView { - domain: String, + domain_info: DomainInfoRead, wrong_code: bool, } #[derive(Template)] #[template(path = "credentials_reset.html")] struct CredResetView { - domain: String, + domain_info: DomainInfoRead, names: String, credentials_update_partial: CredResetPartialView, } @@ -571,6 +571,7 @@ pub(crate) async fn view_self_reset_get( Extension(kopid): Extension, HxRequest(_hx_request): HxRequest, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, mut jar: CookieJar, ) -> axum::response::Result { let uat: UserAuthToken = state @@ -589,13 +590,7 @@ pub(crate) async fn view_self_reset_get( .map_err(|op_err| HtmxError::new(&kopid, op_err)) .await?; - let domain_display_name = state - .qe_r_ref - .get_domain_display_name(kopid.eventid) - .await - .unwrap_or_default(); - - let cu_resp = get_cu_response(domain_display_name, cu_status); + let cu_resp = get_cu_response(domain_info, cu_status); jar = add_cu_cookie(jar, &state, cu_session_token); Ok((jar, cu_resp).into_response()) @@ -606,6 +601,7 @@ pub(crate) async fn view_self_reset_get( kopid, jar, "/ui/update_credentials", + domain_info, ) .await } @@ -629,14 +625,10 @@ pub(crate) async fn view_reset_get( Extension(kopid): Extension, HxRequest(_hx_request): HxRequest, VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, Query(params): Query, mut jar: CookieJar, ) -> axum::response::Result { - let domain_display_name = state - .qe_r_ref - .get_domain_display_name(kopid.eventid) - .await - .unwrap_or_default(); let push_url = HxPushUrl(Uri::from_static("/ui/reset")); let cookie = jar.get(COOKIE_CU_SESSION_TOKEN); if let Some(cookie) = cookie { @@ -669,7 +661,7 @@ pub(crate) async fn view_reset_get( }; // CU Session cookie is okay - let cu_resp = get_cu_response(domain_display_name, cu_status); + let cu_resp = get_cu_response(domain_info, cu_status); Ok(cu_resp) } else if let Some(token) = params.token { // We have a reset token and want to create a new session @@ -679,14 +671,14 @@ pub(crate) async fn view_reset_get( .await { Ok((cu_session_token, cu_status)) => { - let cu_resp = get_cu_response(domain_display_name, cu_status); + let cu_resp = get_cu_response(domain_info, cu_status); jar = add_cu_cookie(jar, &state, cu_session_token); Ok((jar, cu_resp).into_response()) } Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => { let cred_form_view = ResetCredFormView { - domain: domain_display_name.clone(), + domain_info, wrong_code: true, }; @@ -699,7 +691,7 @@ pub(crate) async fn view_reset_get( } } else { let cred_form_view = ResetCredFormView { - domain: domain_display_name.clone(), + domain_info, wrong_code: false, }; // We don't have any credential, show reset token input form @@ -744,7 +736,7 @@ fn get_cu_partial_response(cu_status: CUStatus) -> Response { .into_response() } -fn get_cu_response(domain: String, cu_status: CUStatus) -> Response { +fn get_cu_response(domain_info: DomainInfoRead, cu_status: CUStatus) -> Response { let spn = cu_status.spn.clone(); let displayname = cu_status.displayname.clone(); let (username, _domain) = spn.split_once('@').unwrap_or(("", &spn)); @@ -753,7 +745,7 @@ fn get_cu_response(domain: String, cu_status: CUStatus) -> Response { ( HxPushUrl(Uri::from_static("/ui/reset")), HtmlTemplate(CredResetView { - domain, + domain_info, names, credentials_update_partial, }), diff --git a/server/core/src/lib.rs b/server/core/src/lib.rs index 1176490f4..9254a400c 100644 --- a/server/core/src/lib.rs +++ b/server/core/src/lib.rs @@ -556,7 +556,7 @@ pub async fn domain_rename_core(config: &Configuration) { let new_domain_name = config.domain.as_str(); // make sure we're actually changing the domain name... - match qs.read().await.and_then(|mut qs| qs.get_db_domain_name()) { + match qs.read().await.map(|qs| qs.get_domain_name().to_string()) { Ok(old_domain_name) => { admin_info!(?old_domain_name, ?new_domain_name); if old_domain_name == new_domain_name { diff --git a/server/core/templates/credentials_reset.html b/server/core/templates/credentials_reset.html index be81660c8..f34c4bdb0 100644 --- a/server/core/templates/credentials_reset.html +++ b/server/core/templates/credentials_reset.html @@ -12,9 +12,9 @@

Updating Credentials

(( names ))

-

(( domain ))

+

(( domain_info.display_name() ))

(( credentials_update_partial|safe )) -(% endblock %) \ No newline at end of file +(% endblock %) diff --git a/server/core/templates/credentials_reset_form.html b/server/core/templates/credentials_reset_form.html index c47c2f3a7..3907a9e8f 100644 --- a/server/core/templates/credentials_reset_form.html +++ b/server/core/templates/credentials_reset_form.html @@ -13,7 +13,7 @@

Credential Reset

-

(( domain ))

+

(( domain_info.display_name() ))

diff --git a/server/core/templates/login_base.html b/server/core/templates/login_base.html index 7d7d1117a..867442087 100644 --- a/server/core/templates/login_base.html +++ b/server/core/templates/login_base.html @@ -8,7 +8,11 @@ (% block body %)
+ (% if domain_custom_image %) + + (% else %) + (% endif %)

Kanidm

diff --git a/server/lib/src/constants/acp.rs b/server/lib/src/constants/acp.rs index 3abd4216e..65bac5daf 100644 --- a/server/lib/src/constants/acp.rs +++ b/server/lib/src/constants/acp.rs @@ -907,6 +907,61 @@ lazy_static! { }; } +lazy_static! { + pub static ref IDM_ACP_DOMAIN_ADMIN_DL8: BuiltinAcp = BuiltinAcp { + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlModify, + EntryClass::AccessControlSearch + ], + name: "idm_acp_domain_admin", + uuid: UUID_IDM_ACP_DOMAIN_ADMIN_V1, + description: "Builtin IDM Control for granting domain info administration locally", + receiver: BuiltinAcpReceiver::Group(vec![UUID_DOMAIN_ADMINS]), + target: BuiltinAcpTarget::Filter(ProtoFilter::And(vec![ + ProtoFilter::Eq( + Attribute::Uuid.to_string(), + STR_UUID_DOMAIN_INFO.to_string() + ), + FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone() + ])), + search_attrs: vec![ + Attribute::Class, + Attribute::Name, + Attribute::Uuid, + Attribute::DomainDisplayName, + Attribute::DomainName, + Attribute::DomainLdapBasedn, + Attribute::DomainSsid, + Attribute::DomainUuid, + Attribute::KeyInternalData, + Attribute::LdapAllowUnixPwBind, + Attribute::Version, + Attribute::Image, + ], + modify_removed_attrs: vec![ + Attribute::DomainDisplayName, + Attribute::DomainSsid, + Attribute::DomainLdapBasedn, + Attribute::LdapAllowUnixPwBind, + Attribute::KeyActionRevoke, + Attribute::KeyActionRotate, + Attribute::Image, + ], + modify_present_attrs: vec![ + Attribute::DomainDisplayName, + Attribute::DomainLdapBasedn, + Attribute::DomainSsid, + Attribute::LdapAllowUnixPwBind, + Attribute::KeyActionRevoke, + Attribute::KeyActionRotate, + Attribute::Image, + ], + ..Default::default() + }; +} + lazy_static! { pub static ref IDM_ACP_SYNC_ACCOUNT_MANAGE_V1: BuiltinAcp = BuiltinAcp { classes: vec![ diff --git a/server/lib/src/constants/schema.rs b/server/lib/src/constants/schema.rs index 5a0664ed0..44dff84f6 100644 --- a/server/lib/src/constants/schema.rs +++ b/server/lib/src/constants/schema.rs @@ -1113,6 +1113,29 @@ pub static ref SCHEMA_CLASS_DOMAIN_INFO_DL7: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_DOMAIN_INFO_DL8: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_DOMAIN_INFO, + name: EntryClass::DomainInfo.into(), + description: "Local domain information and configuration".to_string(), + + systemmay: vec![ + Attribute::DomainSsid.into(), + Attribute::DomainLdapBasedn.into(), + Attribute::LdapAllowUnixPwBind.into(), + Attribute::Image.into(), + Attribute::PatchLevel.into(), + Attribute::DomainDevelopmentTaint.into(), + ], + systemmust: vec![ + Attribute::Name.into(), + Attribute::DomainUuid.into(), + Attribute::DomainName.into(), + Attribute::DomainDisplayName.into(), + Attribute::Version.into(), + ], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_POSIXGROUP: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_POSIXGROUP, name: EntryClass::PosixGroup.into(), diff --git a/server/lib/src/idm/server.rs b/server/lib/src/idm/server.rs index 60fa0ebbf..1c246ca1a 100644 --- a/server/lib/src/idm/server.rs +++ b/server/lib/src/idm/server.rs @@ -6,6 +6,7 @@ use kanidm_lib_crypto::CryptoPolicy; use compact_jwt::{Jwk, JwsCompact}; use concread::bptree::{BptreeMap, BptreeMapReadTxn, BptreeMapWriteTxn}; +use concread::cowcell::CowCellReadTxn; use concread::hashmap::HashMap; use kanidm_proto::internal::{ ApiToken, BackupCodesView, CredentialStatus, PasswordFeedback, RadiusAuthToken, ScimSyncToken, @@ -56,12 +57,15 @@ use crate::idm::unix::{UnixGroup, UnixUserAccount}; use crate::idm::AuthState; use crate::prelude::*; use crate::server::keys::KeyProvidersTransaction; +use crate::server::DomainInfo; use crate::utils::{password_from_random, readable_password_from_random, uuid_from_duration, Sid}; use crate::value::{Session, SessionState}; pub(crate) type AuthSessionMutex = Arc>; pub(crate) type CredSoftLockMutex = Arc>; +pub type DomainInfoRead = CowCellReadTxn; + pub struct IdmServer { // There is a good reason to keep this single thread - it // means that limits to sessions can be easily applied and checked to @@ -246,6 +250,14 @@ impl IdmServer { }) } + /// Begin a fast (low cost) read of the servers domain info. It is important to note + /// this does not conflict with any other type of transaction type and may safely + /// beheld over other transaction boundaries. + #[instrument(level = "debug", skip_all)] + pub fn domain_read(&self) -> DomainInfoRead { + self.qs.d_info.read() + } + /// Read from the database, in a transaction. #[instrument(level = "debug", skip_all)] pub async fn proxy_read(&self) -> Result, OperationError> { diff --git a/server/lib/src/plugins/protected.rs b/server/lib/src/plugins/protected.rs index 964a945c0..4559d701c 100644 --- a/server/lib/src/plugins/protected.rs +++ b/server/lib/src/plugins/protected.rs @@ -34,6 +34,7 @@ lazy_static! { Attribute::BadlistPassword, Attribute::DeniedName, Attribute::DomainDisplayName, + Attribute::Image, // modification of account policy values for dyngroup. Attribute::AuthSessionExpiry, Attribute::PrivilegeExpiry, diff --git a/server/lib/src/server/migrations.rs b/server/lib/src/server/migrations.rs index 99308d280..a0791c7f2 100644 --- a/server/lib/src/server/migrations.rs +++ b/server/lib/src/server/migrations.rs @@ -599,6 +599,7 @@ impl<'a> QueryServerWriteTransaction<'a> { SCHEMA_ATTR_APPLICATION_PASSWORD_DL8.clone().into(), SCHEMA_CLASS_APPLICATION_DL8.clone().into(), SCHEMA_CLASS_PERSON_DL8.clone().into(), + SCHEMA_CLASS_DOMAIN_INFO_DL8.clone().into(), ]; idm_schema_classes @@ -622,6 +623,7 @@ impl<'a> QueryServerWriteTransaction<'a> { BUILTIN_GROUP_MAIL_SERVICE_ADMINS_DL8.clone().try_into()?, BUILTIN_IDM_MAIL_SERVERS_DL8.clone().try_into()?, IDM_ACP_MAIL_SERVERS_DL8.clone().into(), + IDM_ACP_DOMAIN_ADMIN_DL8.clone().into(), ]; idm_data diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index c25e1ecb3..ac5031961 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -11,7 +11,7 @@ use std::collections::BTreeSet; use tokio::sync::{Semaphore, SemaphorePermit}; use tracing::trace; -use kanidm_proto::internal::{DomainInfo as ProtoDomainInfo, UiHint}; +use kanidm_proto::internal::{DomainInfo as ProtoDomainInfo, ImageValue, UiHint}; use crate::be::{Backend, BackendReadTransaction, BackendTransaction, BackendWriteTransaction}; // We use so many, we just import them all ... @@ -66,6 +66,8 @@ pub(crate) enum ServerPhase { Running, } +/// Domain Information. This should not contain sensitive information, the data within +/// this structure may be used for public presentation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DomainInfo { pub(crate) d_uuid: Uuid, @@ -75,6 +77,26 @@ pub struct DomainInfo { pub(crate) d_patch_level: u32, pub(crate) d_devel_taint: bool, pub(crate) d_ldap_allow_unix_pw_bind: bool, + // In future this should be image reference instead of the image itself. + d_image: Option, +} + +impl DomainInfo { + pub fn name(&self) -> &str { + self.d_name.as_str() + } + + pub fn display_name(&self) -> &str { + self.d_display.as_str() + } + + pub fn devel_taint(&self) -> bool { + self.d_devel_taint + } + + pub fn image(&self) -> Option<&ImageValue> { + self.d_image.as_ref() + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -209,6 +231,8 @@ pub trait QueryServerTransaction<'a> { fn get_domain_display_name(&self) -> &str; + fn get_domain_image_value(&self) -> Option; + fn get_resolve_filter_cache(&mut self) -> &mut ResolveFilterCacheReadTxn<'a>; // Because of how borrowck in rust works, if we need to get two inner types we have to get them @@ -891,19 +915,8 @@ pub trait QueryServerTransaction<'a> { } } - /// Pull the domain name from the database - fn get_db_domain_name(&mut self) -> Result { + fn get_db_domain(&mut self) -> Result, OperationError> { self.internal_search_uuid(UUID_DOMAIN_INFO) - .and_then(|e| { - trace!(?e); - e.get_ava_single_iname(Attribute::DomainName) - .map(str::to_string) - .ok_or(OperationError::InvalidEntryState) - }) - .map_err(|e| { - admin_error!(?e, "Error getting domain name"); - e - }) } fn get_domain_key_object_handle(&self) -> Result, OperationError> { @@ -1105,6 +1118,10 @@ impl<'a> QueryServerTransaction<'a> for QueryServerReadTransaction<'a> { fn get_domain_display_name(&self) -> &str { &self.d_info.d_display } + + fn get_domain_image_value(&self) -> Option { + self.d_info.d_image.clone() + } } impl<'a> QueryServerReadTransaction<'a> { @@ -1257,6 +1274,10 @@ impl<'a> QueryServerTransaction<'a> for QueryServerWriteTransaction<'a> { fn get_domain_display_name(&self) -> &str { &self.d_info.d_display } + + fn get_domain_image_value(&self) -> Option { + self.d_info.d_image.clone() + } } impl QueryServer { @@ -1294,6 +1315,7 @@ impl QueryServer { // Automatically derive our current taint mode based on the PRERELEASE setting. d_devel_taint: option_env!("KANIDM_PRE_RELEASE").is_some(), d_ldap_allow_unix_pw_bind: false, + d_image: None, })); let cid = Cid::new_lamport(s_uuid, curtime, &ts_max); @@ -1840,20 +1862,6 @@ impl<'a> QueryServerWriteTransaction<'a> { }) } - fn get_db_domain_display_name(&mut self) -> Result { - self.internal_search_uuid(UUID_DOMAIN_INFO) - .and_then(|e| { - trace!(?e); - e.get_ava_single_utf8(Attribute::DomainDisplayName) - .map(str::to_string) - .ok_or(OperationError::InvalidEntryState) - }) - .map_err(|e| { - admin_error!(?e, "Error getting domain display name"); - e - }) - } - #[instrument(level = "debug", skip_all)] pub(crate) fn reload_key_material(&mut self) -> Result<(), OperationError> { let filt = filter!(f_eq(Attribute::Class, EntryClass::KeyProvider.into())); @@ -1988,16 +1996,26 @@ impl<'a> QueryServerWriteTransaction<'a> { /// Pulls the domain name from the database and updates the DomainInfo data in memory #[instrument(level = "debug", skip_all)] pub(crate) fn reload_domain_info(&mut self) -> Result<(), OperationError> { - let domain_name = self.get_db_domain_name()?; - let display_name = self.get_db_domain_display_name()?; - let domain_ldap_allow_unix_pw_bind = match self.get_domain_ldap_allow_unix_pw_bind() { - Ok(v) => v, - _ => { - admin_warn!("Defaulting ldap_allow_unix_pw_bind to true"); - true - } - }; + let domain_entry = self.get_db_domain()?; + + let domain_name = domain_entry + .get_ava_single_iname(Attribute::DomainName) + .map(str::to_string) + .ok_or(OperationError::InvalidEntryState)?; + + let display_name = domain_entry + .get_ava_single_utf8(Attribute::DomainDisplayName) + .map(str::to_string) + .ok_or(OperationError::InvalidEntryState)?; + + let domain_ldap_allow_unix_pw_bind = domain_entry + .get_ava_single_bool(Attribute::LdapAllowUnixPwBind) + .unwrap_or(true); + + let domain_image = domain_entry.get_ava_single_image(Attribute::Image); + let domain_uuid = self.be_txn.get_db_d_uuid()?; + let mut_d_info = self.d_info.get_mut(); mut_d_info.d_ldap_allow_unix_pw_bind = domain_ldap_allow_unix_pw_bind; if mut_d_info.d_uuid != domain_uuid { @@ -2020,6 +2038,7 @@ impl<'a> QueryServerWriteTransaction<'a> { mut_d_info.d_name = domain_name; } mut_d_info.d_display = display_name; + mut_d_info.d_image = domain_image; Ok(()) } diff --git a/server/lib/src/valueset/image/mod.rs b/server/lib/src/valueset/image/mod.rs index 762b4f1aa..7a3bfb5de 100644 --- a/server/lib/src/valueset/image/mod.rs +++ b/server/lib/src/valueset/image/mod.rs @@ -20,8 +20,7 @@ pub struct ValueSetImage { pub(crate) const MAX_IMAGE_HEIGHT: u32 = 1024; pub(crate) const MAX_IMAGE_WIDTH: u32 = 1024; -/// 128kb should be enough for anyone... right? :D -pub(crate) const MAX_FILE_SIZE: u32 = 1024 * 128; +pub(crate) const MAX_FILE_SIZE: u32 = 1024 * 256; const WEBP_MAGIC: &[u8; 4] = b"RIFF"; diff --git a/tools/cli/src/cli/domain/mod.rs b/tools/cli/src/cli/domain/mod.rs index addd84829..e9a79245e 100644 --- a/tools/cli/src/cli/domain/mod.rs +++ b/tools/cli/src/cli/domain/mod.rs @@ -1,11 +1,16 @@ use crate::common::OpType; use crate::{handle_client_error, DomainOpt}; +use anyhow::{Context, Error}; +use kanidm_proto::internal::ImageValue; +use std::fs::read; impl DomainOpt { pub fn debug(&self) -> bool { match self { DomainOpt::SetDisplayName(copt) => copt.copt.debug, DomainOpt::SetLdapBasedn { copt, .. } + | DomainOpt::SetImage { copt, .. } + | DomainOpt::RemoveImage { copt } | DomainOpt::SetLdapAllowUnixPasswordBind { copt, .. } | DomainOpt::RevokeKey { copt, .. } | DomainOpt::Show(copt) => copt.debug, @@ -60,6 +65,58 @@ impl DomainOpt { Err(e) => handle_client_error(e, copt.output_mode), } } + DomainOpt::SetImage { + copt, + path, + image_type, + } => { + let client = copt.to_client(OpType::Write).await; + let img_res: Result = (move || { + let file_name = path + .file_name() + .context("Please pass a file")? + .to_str() + .context("Path contains non utf-8")? + .to_string(); + + let image_type = if let Some(image_type) = image_type { + image_type.as_str().try_into().map_err(Error::msg)? + } else { + path + .extension().context("Path has no extension so we can't infer the imageType, or you could pass the optional imageType argument yourself.")? + .to_str().context("Path contains invalid utf-8")? + .try_into() + .map_err(Error::msg)? + }; + + let read_res = read(path); + match read_res { + Ok(data) => Ok(ImageValue::new(file_name, image_type, data)), + Err(err) => Err(err).context("Reading error"), + } + })(); + + let img = match img_res { + Ok(img) => img, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + match client.idm_domain_update_image(img).await { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, copt.output_mode), + } + } + DomainOpt::RemoveImage { copt } => { + let client = copt.to_client(OpType::Write).await; + + match client.idm_domain_delete_image().await { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, copt.output_mode), + } + } } } } diff --git a/tools/cli/src/opt/kanidm.rs b/tools/cli/src/opt/kanidm.rs index 2f2da6354..f8d909dfb 100644 --- a/tools/cli/src/opt/kanidm.rs +++ b/tools/cli/src/opt/kanidm.rs @@ -1274,6 +1274,22 @@ pub enum DomainOpt { copt: CommonOpt, key_id: String, }, + /// The image presented as the instance logo + #[clap(name="set-image")] + SetImage { + #[clap(flatten)] + copt: CommonOpt, + #[clap(name = "file-path")] + path: PathBuf, + #[clap(name = "image-type")] + image_type: Option, + }, + /// The remove the current instance logo, reverting to the default. + #[clap(name="remove-image")] + RemoveImage { + #[clap(flatten)] + copt: CommonOpt, + }, } #[derive(Debug, Subcommand)]