diff --git a/nixos/tests/kanidm-provisioning.nix b/nixos/tests/kanidm-provisioning.nix index 8813be497abe..133bd0d10cb2 100644 --- a/nixos/tests/kanidm-provisioning.nix +++ b/nixos/tests/kanidm-provisioning.nix @@ -18,7 +18,7 @@ in name = "kanidm-provisioning-${kanidmPackage.version}"; meta.maintainers = with pkgs.lib.maintainers; [ oddlama ]; - _module.args.kanidmPackage = pkgs.lib.mkDefault pkgs.kanidmWithSecretProvisioning_1_8; + _module.args.kanidmPackage = pkgs.lib.mkDefault pkgs.kanidmWithSecretProvisioning_1_10; nodes.provision = { pkgs, lib, ... }: diff --git a/nixos/tests/kanidm.nix b/nixos/tests/kanidm.nix index 3edc4a8b239f..1d17d84908cf 100644 --- a/nixos/tests/kanidm.nix +++ b/nixos/tests/kanidm.nix @@ -22,7 +22,7 @@ in oddlama ]; - _module.args.kanidmPackage = pkgs.lib.mkDefault pkgs.kanidm_1_8; + _module.args.kanidmPackage = pkgs.lib.mkDefault pkgs.kanidm_1_10; nodes.server = { pkgs, ... }: diff --git a/pkgs/servers/kanidm/1_10.nix b/pkgs/servers/kanidm/1_10.nix new file mode 100644 index 000000000000..f87019c71ebb --- /dev/null +++ b/pkgs/servers/kanidm/1_10.nix @@ -0,0 +1,5 @@ +import ./generic.nix { + version = "1.10.0"; + hash = "sha256-VKzeyhps9NC+tU7NaGZf2kTCCK6J5LU1ewJBhS/Noek="; + cargoHash = "sha256-hvWZLGJBsMbpZQixafdYzqT+zMbk2vAtPYpJ4OBVhoU="; +} diff --git a/pkgs/servers/kanidm/1_9.nix b/pkgs/servers/kanidm/1_9.nix index 2952a9f73a67..1e3c888071e3 100644 --- a/pkgs/servers/kanidm/1_9.nix +++ b/pkgs/servers/kanidm/1_9.nix @@ -2,4 +2,5 @@ import ./generic.nix { version = "1.9.3"; hash = "sha256-YI2nShQhsCd1vn7S4VeCELfcU0HsmZrFs60kllJJFAo="; cargoHash = "sha256-b6kBDA/CDJHbybfvbRRZ++q9H+SVRu0BmgkwzN2i/YU="; + eolDate = "2026-05-31"; } diff --git a/pkgs/servers/kanidm/generic.nix b/pkgs/servers/kanidm/generic.nix index dfaf78ba4b86..fcd8c3e543a9 100644 --- a/pkgs/servers/kanidm/generic.nix +++ b/pkgs/servers/kanidm/generic.nix @@ -59,6 +59,8 @@ rustPlatform.buildRustPackage (finalAttrs: { inherit hash; }; + __structuredAttrs = true; + env.KANIDM_BUILD_PROFILE = "release_nixpkgs_${arch}"; patches = @@ -105,13 +107,15 @@ rustPlatform.buildRustPackage (finalAttrs: { ]; buildInputs = [ - openssl sqlite pam rust-jemalloc-sys ] ++ lib.optionals stdenv.hostPlatform.isLinux [ udev + ] + ++ lib.optionals (lib.versionOlder finalAttrs.version "1.10") [ + openssl ]; # The UI needs to be in place before the tests are run. @@ -133,6 +137,9 @@ rustPlatform.buildRustPackage (finalAttrs: { ''profile.release.lto="off"'' ]; + # A bunch of the tests break due to the sandboxing. + doCheck = false; + preFixup = '' installShellCompletion \ --bash $releaseDir/build/completions/*.bash \ diff --git a/pkgs/servers/kanidm/provision-patches/1_10/oauth2-basic-secret-modify.patch b/pkgs/servers/kanidm/provision-patches/1_10/oauth2-basic-secret-modify.patch new file mode 100644 index 000000000000..5101273d30d1 --- /dev/null +++ b/pkgs/servers/kanidm/provision-patches/1_10/oauth2-basic-secret-modify.patch @@ -0,0 +1,159 @@ +From 5b97267c49def10f5a4b7bb372963261c0f4b08d Mon Sep 17 00:00:00 2001 +From: oddlama +Date: Fri, 1 May 2026 15:01:05 +0200 +Subject: [PATCH 1/2] oauth2 basic secret modify + +--- + server/core/src/actors/v1_write.rs | 42 +++++++++++++++++++++++++++++ + server/core/src/https/v1.rs | 6 ++++- + server/core/src/https/v1_oauth2.rs | 29 ++++++++++++++++++++ + server/lib/src/server/migrations.rs | 16 +++++++++++ + 4 files changed, 92 insertions(+), 1 deletion(-) + +diff --git a/server/core/src/actors/v1_write.rs b/server/core/src/actors/v1_write.rs +index 977292ae9..bf79c42a1 100644 +--- a/server/core/src/actors/v1_write.rs ++++ b/server/core/src/actors/v1_write.rs +@@ -326,6 +326,48 @@ impl QueryServerWriteV1 { + .and_then(|_| idms_prox_write.commit().map(|_| ())) + } + ++ #[instrument( ++ level = "info", ++ skip_all, ++ fields(uuid = ?eventid) ++ )] ++ pub async fn handle_oauth2_basic_secret_write( ++ &self, ++ client_auth_info: ClientAuthInfo, ++ filter: Filter, ++ new_secret: String, ++ eventid: Uuid, ++ ) -> Result<(), OperationError> { ++ // Given a protoEntry, turn this into a modification set. ++ 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"); ++ e ++ })?; ++ ++ let modlist = ModifyList::new_purge_and_set( ++ Attribute::OAuth2RsBasicSecret, ++ Value::SecretValue(new_secret), ++ ); ++ ++ let mdf = ++ ModifyEvent::from_internal_parts(ident, &modlist, &filter, &idms_prox_write.qs_write) ++ .map_err(|e| { ++ admin_error!(err = ?e, "Failed to begin modify during handle_oauth2_basic_secret_write"); ++ e ++ })?; ++ ++ trace!(?mdf, "Begin modify event"); ++ ++ idms_prox_write ++ .qs_write ++ .modify(&mdf) ++ .and_then(|_| idms_prox_write.commit()) ++ } ++ + #[instrument( + level = "info", + skip_all, +diff --git a/server/core/src/https/v1.rs b/server/core/src/https/v1.rs +index ba3ea2827..489ce0002 100644 +--- a/server/core/src/https/v1.rs ++++ b/server/core/src/https/v1.rs +@@ -10,7 +10,7 @@ use axum::extract::{Path, State}; + use axum::http::{HeaderMap, HeaderValue}; + use axum::middleware::from_fn; + use axum::response::{IntoResponse, Response}; +-use axum::routing::{delete, get, post, put}; ++use axum::routing::{delete, get, post, put, patch}; + use axum::{Extension, Json, Router}; + use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; + use compact_jwt::{Jwk, Jws, JwsSigner}; +@@ -3156,6 +3156,10 @@ pub(crate) fn route_setup(state: ServerState) -> Router { + "/v1/oauth2/{rs_name}/_basic_secret", + get(super::v1_oauth2::oauth2_id_get_basic_secret), + ) ++ .route( ++ "/v1/oauth2/{rs_name}/_basic_secret", ++ patch(super::v1_oauth2::oauth2_id_patch_basic_secret), ++ ) + .route( + "/v1/oauth2/{rs_name}/_scopemap/{group}", + post(super::v1_oauth2::oauth2_id_scopemap_post) +diff --git a/server/core/src/https/v1_oauth2.rs b/server/core/src/https/v1_oauth2.rs +index fdc3647b4..ed4709d27 100644 +--- a/server/core/src/https/v1_oauth2.rs ++++ b/server/core/src/https/v1_oauth2.rs +@@ -149,6 +149,35 @@ pub(crate) async fn oauth2_id_get_basic_secret( + .map_err(WebError::from) + } + ++#[utoipa::path( ++ patch, ++ path = "/v1/oauth2/{rs_name}/_basic_secret", ++ request_body=ProtoEntry, ++ responses( ++ DefaultApiResponse, ++ ), ++ security(("token_jwt" = [])), ++ tag = "v1/oauth2", ++ operation_id = "oauth2_id_patch_basic_secret" ++)] ++/// Overwrite the basic secret for a given OAuth2 Resource Server. ++#[instrument(level = "info", skip(state, new_secret))] ++pub(crate) async fn oauth2_id_patch_basic_secret( ++ State(state): State, ++ Extension(kopid): Extension, ++ VerifiedClientInformation(client_auth_info): VerifiedClientInformation, ++ Path(rs_name): Path, ++ Json(new_secret): Json, ++) -> Result, WebError> { ++ let filter = oauth2_id(&rs_name); ++ state ++ .qe_w_ref ++ .handle_oauth2_basic_secret_write(client_auth_info, filter, new_secret, kopid.eventid) ++ .await ++ .map(Json::from) ++ .map_err(WebError::from) ++} ++ + #[utoipa::path( + patch, + path = "/v1/oauth2/{rs_name}", +diff --git a/server/lib/src/server/migrations.rs b/server/lib/src/server/migrations.rs +index b3effb95b..5c6a56d24 100644 +--- a/server/lib/src/server/migrations.rs ++++ b/server/lib/src/server/migrations.rs +@@ -220,6 +220,22 @@ impl QueryServer { + reload_required = true; + }; + ++ // secret provisioning: allow idm_admin to modify OAuth2RsBasicSecret. ++ write_txn.internal_modify_uuid( ++ UUID_IDM_ACP_OAUTH2_MANAGE_V1, ++ &ModifyList::new_append( ++ Attribute::AcpCreateAttr, ++ Attribute::OAuth2RsBasicSecret.into(), ++ ), ++ )?; ++ write_txn.internal_modify_uuid( ++ UUID_IDM_ACP_OAUTH2_MANAGE_V1, ++ &ModifyList::new_append( ++ Attribute::AcpModifyPresentAttr, ++ Attribute::OAuth2RsBasicSecret.into(), ++ ), ++ )?; ++ + // Execute whatever operations we have batched up and ready to go. This is needed + // to preserve ordering of the operations - if we reloaded after a remigrate then + // we would have skipped the patch level fix which needs to have occurred *first*. +-- +2.53.0 + diff --git a/pkgs/servers/kanidm/provision-patches/1_10/recover-account.patch b/pkgs/servers/kanidm/provision-patches/1_10/recover-account.patch new file mode 100644 index 000000000000..a462c14d5d1c --- /dev/null +++ b/pkgs/servers/kanidm/provision-patches/1_10/recover-account.patch @@ -0,0 +1,128 @@ +From e8cd69afcee9d8d37233fa998cf9d1fa124ae90d Mon Sep 17 00:00:00 2001 +From: oddlama +Date: Fri, 1 May 2026 15:01:14 +0200 +Subject: [PATCH 2/2] recover account + +--- + server/core/src/actors/internal.rs | 5 +++-- + server/core/src/admin.rs | 6 +++--- + server/daemon/src/main.rs | 24 +++++++++++++++++++++++- + server/daemon/src/opt.rs | 7 +++++++ + 4 files changed, 36 insertions(+), 6 deletions(-) + +diff --git a/server/core/src/actors/internal.rs b/server/core/src/actors/internal.rs +index deed7350d..f4e9e486a 100644 +--- a/server/core/src/actors/internal.rs ++++ b/server/core/src/actors/internal.rs +@@ -189,17 +189,18 @@ impl QueryServerWriteV1 { + + #[instrument( + level = "info", +- skip(self, eventid), ++ skip(self, password, eventid), + fields(uuid = ?eventid) + )] + pub(crate) async fn handle_admin_recover_account( + &self, + name: String, ++ password: Option, + eventid: Uuid, + ) -> Result { + let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await?; +- let pw = idms_prox_write.recover_account(name.as_str(), None)?; ++ let pw = idms_prox_write.recover_account(name.as_str(), password.as_deref())?; + + idms_prox_write.commit().map(|()| pw) + } +diff --git a/server/core/src/admin.rs b/server/core/src/admin.rs +index 6a13dcaab..6b1070113 100644 +--- a/server/core/src/admin.rs ++++ b/server/core/src/admin.rs +@@ -27,7 +27,7 @@ const REPL_CTRL_TIMEOUT: Duration = Duration::from_secs(15); + + #[derive(Serialize, Deserialize, Debug)] + pub enum AdminTaskRequest { +- RecoverAccount { name: String }, ++ RecoverAccount { name: String, password: Option }, + DisableAccount { name: String }, + ShowReplicationCertificate, + ShowReplicationCertificateMetadata, +@@ -416,8 +416,8 @@ async fn handle_client( + + let resp = async { + match req { +- AdminTaskRequest::RecoverAccount { name } => { +- match server_rw.handle_admin_recover_account(name, eventid).await { ++ AdminTaskRequest::RecoverAccount { name, password } => { ++ match server_rw.handle_admin_recover_account(name, password, eventid).await { + Ok(password) => AdminTaskResponse::RecoverAccount { password }, + Err(e) => { + error!(err = ?e, "error during recover-account"); +diff --git a/server/daemon/src/main.rs b/server/daemon/src/main.rs +index 13653a5cb..ae560f9ce 100644 +--- a/server/daemon/src/main.rs ++++ b/server/daemon/src/main.rs +@@ -380,11 +380,32 @@ fn check_file_ownership(opt: &KanidmdParser) -> Result<(), ExitCode> { + + async fn scripting_command(cmd: ScriptingCommand, config: Configuration) -> ExitCode { + match cmd { +- ScriptingCommand::RecoverAccount { name } => { ++ ScriptingCommand::RecoverAccount { name, from_environment } => { ++ let password = if from_environment { ++ match std::env::var("KANIDM_RECOVER_ACCOUNT_PASSWORD_FILE") { ++ Ok(path) => match tokio::fs::read_to_string(&path).await { ++ Ok(contents) => Some(contents), ++ Err(e) => { ++ error!("Failed to read password file '{}': {}", path, e); ++ return ExitCode::FAILURE; ++ } ++ }, ++ Err(_) => match std::env::var("KANIDM_RECOVER_ACCOUNT_PASSWORD") { ++ Ok(val) => Some(val), ++ Err(_) => { ++ error!("Neither KANIDM_RECOVER_ACCOUNT_PASSWORD_FILE nor KANIDM_RECOVER_ACCOUNT_PASSWORD was set"); ++ return ExitCode::FAILURE; ++ } ++ } ++ } ++ } else { ++ None ++ }; + submit_admin_req_json( + config.adminbindpath.as_str(), + AdminTaskRequest::RecoverAccount { + name: name.to_owned(), ++ password, + }, + ) + .await; +@@ -1008,6 +1029,7 @@ async fn kanidm_main(config: Configuration, opt: KanidmdParser) -> ExitCode { + config.adminbindpath.as_str(), + AdminTaskRequest::RecoverAccount { + name: name.to_owned(), ++ password: None, + }, + ) + .await; +diff --git a/server/daemon/src/opt.rs b/server/daemon/src/opt.rs +index 524ba3134..fa3d70f44 100644 +--- a/server/daemon/src/opt.rs ++++ b/server/daemon/src/opt.rs +@@ -128,6 +128,13 @@ enum ScriptingCommand { + #[clap(value_parser)] + /// The account name to recover credentials for. + name: String, ++ /// Use a password given via an environment variable. ++ /// - `KANIDM_RECOVER_ACCOUNT_PASSWORD_FILE` takes precedence and reads the desired ++ /// password from the given file ++ /// - `KANIDM_RECOVER_ACCOUNT_PASSWORD` directly takes a ++ /// password - beware that this will leave the password in the environment ++ #[clap(long = "from-environment")] ++ from_environment: bool, + }, + /// Backup + Backup { +-- +2.53.0 + diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 787d1b82a9fc..9f4fb5eb6498 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -7976,14 +7976,20 @@ with pkgs; kanidm_1_9 = callPackage ../servers/kanidm/1_9.nix { kanidmWithSecretProvisioning = kanidmWithSecretProvisioning_1_9; }; + kanidm_1_10 = callPackage ../servers/kanidm/1_10.nix { + kanidmWithSecretProvisioning = kanidmWithSecretProvisioning_1_10; + }; kanidmWithSecretProvisioning_1_8 = kanidm_1_8.override { enableSecretProvisioning = true; }; kanidmWithSecretProvisioning_1_9 = kanidm_1_9.override { enableSecretProvisioning = true; }; + kanidmWithSecretProvisioning_1_10 = kanidm_1_10.override { enableSecretProvisioning = true; }; }) kanidm_1_8 kanidm_1_9 + kanidm_1_10 kanidmWithSecretProvisioning_1_8 kanidmWithSecretProvisioning_1_9 + kanidmWithSecretProvisioning_1_10 ; lemmy-server = callPackage ../servers/web-apps/lemmy/server.nix { };