20240828 Support Larger Images, Allow Custom Domain Icons (#3016)

Allow setting custom domain icons.
This commit is contained in:
Firstyear 2024-09-05 14:19:27 +10:00 committed by GitHub
parent e5a5de8de3
commit 95fc6fc5bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 611 additions and 196 deletions

68
libs/client/src/domain.rs Normal file
View file

@ -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))
}
}

View file

@ -48,6 +48,7 @@ use webauthn_rs_proto::{
PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse,
};
mod domain;
mod group;
mod oauth;
mod person;

View file

@ -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<FilterInvalid>,
) -> Result<ImageValue, OperationError> {
) -> Result<Option<ImageValue>, 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<String, OperationError> {
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()
}
}

View file

@ -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<FilterInvalid>,
request_filter: Filter<FilterInvalid>,
image: Option<ImageValue>,
) -> 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<FilterInvalid>,
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

View file

@ -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,

View file

@ -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<ServerState> for VerifiedClientInformation {
}
}
pub struct DomainInfo(pub DomainInfoRead);
#[async_trait]
impl FromRequestParts<ServerState> 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<Self, Self::Rejection> {
Ok(DomainInfo(state.qe_r_ref.domain_info_read()))
}
}
#[derive(Debug, Clone)]
pub struct ClientConnInfo {
pub addr: SocketAddr,

View file

@ -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<ServerState>,
Extension(kopid): Extension<KOpId>,
) -> 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)) {

View file

@ -20,7 +20,7 @@ pub async fn dont_cache_me(request: Request<Body>, next: Next) -> Response {
}
/// Adds a cache control header of 300 seconds to the response headers.
pub async fn cache_me(request: Request<Body>, next: Next) -> Response {
pub async fn cache_me_short(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
header::CACHE_CONTROL,

View file

@ -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)
}

View file

@ -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()
}
}
}

View file

@ -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<ServerState> {
pub(crate) async fn ui_handler_user_ui(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
DomainInfo(domain_info): DomainInfo,
) -> Response<String> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
DomainInfo(domain_info): DomainInfo,
) -> Response<String> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
DomainInfo(domain_info): DomainInfo,
) -> Response<String> {
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<String> {
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<String> = 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,
);

View file

@ -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<ServerState> {
"/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<ServerState> {
.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)

View file

@ -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<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
) -> Result<Json<()>, 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<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
mut multipart: axum::extract::Multipart,
) -> Result<Json<()>, WebError> {
// because we might not get an image
let mut image: Option<ImageValue> = 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(),
))),
}
}

View file

@ -478,7 +478,7 @@ pub(crate) async fn oauth2_id_image_delete(
) -> Result<Json<()>, 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)

View file

@ -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<Mech<'a>>,
}
@ -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<Response> {
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<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
Extension(kopid): Extension<KOpId>,
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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Form(login_begin_form): Form<LoginBeginForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Form(login_mech_form): Form<LoginMechForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Form(login_totp_form): Form<LoginTotpForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Form(login_pw_form): Form<LoginPwForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Form(login_bc_form): Form<LoginBackupCodeForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Json(assertion): Json<Box<PublicKeyCredential>>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Json(assertion): Json<Box<PublicKeyCredential>>,
) -> 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::<SessionContext>(&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<Response, OperationError> {
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,
})

View file

@ -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<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
Extension(kopid): Extension<KOpId>,
jar: CookieJar,
) -> axum::response::Result<Response> {
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
}

View file

@ -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<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
mut jar: CookieJar,
) -> axum::response::Result<Response> {
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<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
Query(params): Query<ResetTokenParam>,
mut jar: CookieJar,
) -> axum::response::Result<Response> {
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,
}),

View file

@ -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 {

View file

@ -12,9 +12,9 @@
<div class="py-3 text-center">
<h3>Updating Credentials</h3>
<p>(( names ))</p>
<p>(( domain ))</p>
<p>(( domain_info.display_name() ))</p>
</div>
(( credentials_update_partial|safe ))
</main>
</div>
(% endblock %)
(% endblock %)

View file

@ -13,7 +13,7 @@
<center>
<img src="/pkg/img/logo-square.svg" alt="Kanidm" class="kanidm_logo"/>
<h2>Credential Reset</h2>
<h3>(( domain ))</h3>
<h3>(( domain_info.display_name() ))</h3>
</center>
<form class="mb-3">
<div>

View file

@ -8,7 +8,11 @@
(% block body %)
<main id="main" class="flex-shrink-0 form-signin">
<center>
(% if domain_custom_image %)
<img src="/ui/images/domain" alt="Kanidm" class="kanidm_logo"/>
(% else %)
<img src="/pkg/img/logo-square.svg" alt="Kanidm" class="kanidm_logo"/>
(% endif %)
<h3>Kanidm</h3>
</center>
<div id="login-form-container" class="container">

View file

@ -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![

View file

@ -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(),

View file

@ -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<Mutex<AuthSession>>;
pub(crate) type CredSoftLockMutex = Arc<Mutex<CredSoftLock>>;
pub type DomainInfoRead = CowCellReadTxn<DomainInfo>;
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<IdmServerProxyReadTransaction<'_>, OperationError> {

View file

@ -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,

View file

@ -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

View file

@ -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<ImageValue>,
}
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<ImageValue>;
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<String, OperationError> {
fn get_db_domain(&mut self) -> Result<Arc<EntrySealedCommitted>, 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<Arc<KeyObject>, 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<ImageValue> {
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<ImageValue> {
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<String, OperationError> {
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(())
}

View file

@ -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";

View file

@ -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<ImageValue, Error> = (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),
}
}
}
}
}

View file

@ -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<String>,
},
/// The remove the current instance logo, reverting to the default.
#[clap(name="remove-image")]
RemoveImage {
#[clap(flatten)]
copt: CommonOpt,
},
}
#[derive(Debug, Subcommand)]