diff --git a/Cargo.lock b/Cargo.lock index 2b440ec66..d786066c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + [[package]] name = "asn1-rs" version = "0.3.1" @@ -975,6 +984,17 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "cron" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff76b51e4c068c52bfd2866e1567bee7c567ae8f24ada09fd4307019e25eab7" +dependencies = [ + "chrono", + "nom 7.1.1", + "once_cell", +] + [[package]] name = "crossbeam" version = "0.8.2" @@ -1042,6 +1062,12 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -1283,6 +1309,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.8.1" @@ -1320,6 +1352,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if 1.0.0", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.3.7" @@ -1331,6 +1373,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "discard" version = "1.0.4" @@ -1360,6 +1413,15 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +[[package]] +name = "ena" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7402b94a93c24e742487327a7cd839dc9d36fec9de9fb25b09f2dae459f36c3" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -1468,6 +1530,12 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.0.24" @@ -2247,8 +2315,10 @@ name = "kanidm-ipa-sync" version = "1.1.0-alpha.11-dev" dependencies = [ "base64urlsafedata", + "chrono", "clap", "clap_complete", + "cron", "kanidm_client", "kanidm_proto", "kanidmd_lib", @@ -2515,6 +2585,38 @@ dependencies = [ "log", ] +[[package]] +name = "lalrpop" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30455341b0e18f276fa64540aff54deafb54c589de6aca68659c63dd2d5d823" +dependencies = [ + "ascii-canvas", + "atty", + "bit-set", + "diff", + "ena", + "itertools 0.10.5", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", +] + +[[package]] +name = "lalrpop-util" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf796c978e9b4d983414f4caedc9273aa33ee214c5b887bd55fde84c85d2dc4" +dependencies = [ + "regex", +] + [[package]] name = "last-git-commit" version = "0.2.0" @@ -2544,7 +2646,7 @@ dependencies = [ [[package]] name = "ldap3_client" version = "0.3.0" -source = "git+https://github.com/kanidm/ldap3.git#00188ddc176adefd93ab16f3d004b13326716e07" +source = "git+https://github.com/kanidm/ldap3.git#5149451559dc027a7101d3d55b03b4c206c7a40d" dependencies = [ "base64 0.13.1", "base64urlsafedata", @@ -2563,11 +2665,13 @@ dependencies = [ [[package]] name = "ldap3_proto" version = "0.3.0" -source = "git+https://github.com/kanidm/ldap3.git#00188ddc176adefd93ab16f3d004b13326716e07" +source = "git+https://github.com/kanidm/ldap3.git#5149451559dc027a7101d3d55b03b4c206c7a40d" dependencies = [ "bytes", + "lalrpop", + "lalrpop-util", "lber", - "nom 7.1.1", + "regex", "tokio-util", "tracing", "uuid", @@ -2836,6 +2940,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + [[package]] name = "nom" version = "2.2.1" @@ -3179,6 +3289,25 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "petgraph" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5014253a1331579ce62aa67443b4a658c5e7dd03d4bc6d302b94474888143" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + [[package]] name = "phonenumber" version = "0.3.1+8.12.9" @@ -3199,6 +3328,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "pico-args" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468" + [[package]] name = "pin-project" version = "1.0.12" @@ -3302,6 +3437,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro-crate" version = "1.2.1" @@ -3716,6 +3857,12 @@ dependencies = [ "nom 7.1.1", ] +[[package]] +name = "rustversion" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5583e89e108996506031660fe09baa5011b9dd0341b89029313006d1fb508d70" + [[package]] name = "ryu" version = "1.0.11" @@ -4058,6 +4205,12 @@ dependencies = [ "event-listener", ] +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + [[package]] name = "sketching" version = "1.1.0-alpha.11-dev" @@ -4200,6 +4353,19 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "string_cache" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213494b7a2b503146286049378ce02b482200519accc31872ee8be91fa820a08" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "strsim" version = "0.10.0" @@ -4258,6 +4424,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.1.3" @@ -4471,6 +4648,15 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinytemplate" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 1a486d29a..93478df2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,12 +45,13 @@ base64urlsafedata = "0.1.2" bytes = "^1.3.0" clap = { version = "^3.2", features = ["derive"] } clap_complete = "^3.2.5" -# Forced by saffron +# Forced by saffron/cron chrono = "^0.4.23" compact_jwt = "^0.2.3" # compact_jwt = { path = "../compact_jwt" } concread = "^0.4.0" # concread = { path = "../concread" } +cron = "0.12.0" crossbeam = "0.8.1" criterion = "^0.4.0" csv = "1.1.6" diff --git a/examples/kanidm-ipa-sync b/examples/kanidm-ipa-sync new file mode 100644 index 000000000..d0a94620b --- /dev/null +++ b/examples/kanidm-ipa-sync @@ -0,0 +1,38 @@ + +# The sync account token as generated by "system sync generate-token". +sync_token = "eyJhb..." + +# A cron-like expression of when to run when in scheduled mode. The format is: +# sec min hour day of month month day of week year +# +# The default of this value is "0 */5 * * * * *" which means "run every 5 minutes". +# schedule = "" + +# If you want to monitor the status of the scheduled sync tool (you should) +# then you can set a bind address here. +# +# If not set, defaults to no status listener. +# status_bind = "" + +# The LDAP URI to FreeIPA. This MUST be LDAPS. You should connect to a unique single +# server in the IPA topology rather than via a load balancer or dns srv records. This +# is to prevent replication conflicts and issues due to how 389-ds content sync works. +ipa_uri = "ldaps://specific-server.ipa.dev.kanidm.com" +# Path to the IPA CA certificate in PEM format. +ipa_ca = "/path/to/kanidm-ipa-ca.pem" +# The DN of an account with content sync rights. By default cn=Directory Manager has +# this access. +ipa_sync_dn = "cn=Directory Manager" +ipa_sync_pw = "pi9aix6balaqu8Maerah" +# The basedn to examine. +ipa_sync_base_dn = "dc=ipa,dc=dev,dc=kanidm,dc=com" + +# The sync tool can alter or exclude entries. These are mapped by their syncuuid +# (not their ipa-object-uuid). The syncuuid is derived from nsUniqueId in 389-ds. +# This is chosen oven DN because DN's can change with modrdn where nsUniqueId is +# immutable and requires an entry to be deleted and recreated. + +[ac60034b-3498-11ed-a50d-919b4b1a5ec0] +# my-problematic-entry +exclude = true + diff --git a/iam_migrations/freeipa/00config-mod.ldif b/iam_migrations/freeipa/00config-mod.ldif index bf7b17f46..51f93ebaa 100644 --- a/iam_migrations/freeipa/00config-mod.ldif +++ b/iam_migrations/freeipa/00config-mod.ldif @@ -1,4 +1,4 @@ dn: cn=Retro Changelog Plugin,cn=plugins,cn=config changetype: modify add: nsslapd-include-suffix -nsslapd-include-suffix: dc=dev,dc=kanidm,dc=com +nsslapd-include-suffix: dc=ipa,dc=dev,dc=kanidm,dc=com diff --git a/iam_migrations/freeipa/Cargo.toml b/iam_migrations/freeipa/Cargo.toml index 69d1ed31b..3eab6f645 100644 --- a/iam_migrations/freeipa/Cargo.toml +++ b/iam_migrations/freeipa/Cargo.toml @@ -14,9 +14,11 @@ repository.workspace = true [dependencies] base64urlsafedata.workspace = true clap = { workspace = true, features = ["derive", "env"] } +chrono.workspace = true +cron.workspace = true kanidm_client.workspace = true kanidm_proto.workspace = true -tokio = { workspace = true, features = ["rt", "macros"] } +tokio = { workspace = true, features = ["rt", "macros", "net"] } tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } diff --git a/iam_migrations/freeipa/src/config.rs b/iam_migrations/freeipa/src/config.rs index ab7d8c45d..082456c14 100644 --- a/iam_migrations/freeipa/src/config.rs +++ b/iam_migrations/freeipa/src/config.rs @@ -6,6 +6,8 @@ use uuid::Uuid; #[derive(Debug, Deserialize)] pub struct Config { pub sync_token: String, + pub schedule: Option, + pub status_bind: Option, pub ipa_uri: Url, pub ipa_ca: String, pub ipa_sync_dn: String, diff --git a/iam_migrations/freeipa/src/error.rs b/iam_migrations/freeipa/src/error.rs new file mode 100644 index 000000000..d02478577 --- /dev/null +++ b/iam_migrations/freeipa/src/error.rs @@ -0,0 +1,10 @@ +#[derive(Clone, Debug)] +pub enum SyncError { + ClientConfig, + LdapConn, + LdapAuth, + LdapSyncrepl, + SyncStatus, + SyncUpdate, + Preprocess, +} diff --git a/iam_migrations/freeipa/src/main.rs b/iam_migrations/freeipa/src/main.rs index b0182cde5..a1441de76 100644 --- a/iam_migrations/freeipa/src/main.rs +++ b/iam_migrations/freeipa/src/main.rs @@ -12,12 +12,16 @@ #![allow(clippy::expect_used)] mod config; +mod error; #[cfg(test)] mod tests; use crate::config::{Config, EntryConfig}; +use crate::error::SyncError; +use chrono::Utc; use clap::Parser; +use cron::Schedule; use std::collections::HashMap; use std::fs::metadata; use std::fs::File; @@ -25,8 +29,17 @@ use std::io::Read; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; use std::thread; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; use tokio::runtime; +use tokio::sync::broadcast; +use tokio::time::sleep; + use tracing::{debug, error, info, warn}; use tracing_subscriber::prelude::*; use tracing_subscriber::{fmt, EnvFilter}; @@ -84,14 +97,182 @@ async fn driver_main(opt: Opt) { } }; - // Do we need this? - // let cb = cb.connect_timeout(cfg.conn_timeout); + let expression = sync_config.schedule.as_deref().unwrap_or("0 */5 * * * * *"); + let schedule = match Schedule::from_str(expression) { + Ok(s) => s, + Err(_) => { + error!("Failed to parse cron schedule expression"); + return; + } + }; + + if opt.schedule { + let last_op_status = Arc::new(AtomicBool::new(true)); + let (broadcast_tx, mut broadcast_rx) = broadcast::channel(4); + + let last_op_status_c = last_op_status.clone(); + + // Can we setup the socket for status? + + let status_handle = if let Some(sb) = sync_config.status_bind.as_deref() { + // Can we bind? + let listener = match TcpListener::bind(sb).await { + Ok(l) => l, + Err(e) => { + error!(?e, "Failed to bind status socket"); + return; + } + }; + + info!("Status listener is started on {:?}", sb); + // Detach a status listener. + let status_rx = broadcast_tx.subscribe(); + Some(tokio::spawn(async move { + status_task(listener, status_rx, last_op_status_c).await + })) + } else { + warn!("No status listener configured, this will prevent you monitoring the sync tool"); + None + }; + + // main driver loop + let driver_handle = tokio::spawn(async move { + loop { + let now = Utc::now(); + let next_time = match schedule.after(&now).next() { + Some(v) => v, + None => { + error!("Failed to access any future scheduled events, terminating."); + break; + } + }; + + // If we don't do 1 + here we can trigger the event multiple times + // rapidly since we are in the same second. + let wait_seconds = 1 + (next_time - now).num_seconds() as u64; + info!("next sync on {}, wait_time = {}s", next_time, wait_seconds); + + tokio::select! { + _ = broadcast_rx.recv() => { + // stop the event loop! + break; + } + _ = sleep(Duration::from_secs(wait_seconds)) => { + info!("starting sync ..."); + match run_sync(cb.clone(), &sync_config, &opt).await { + Ok(_) => last_op_status.store(true, Ordering::Relaxed), + Err(e) => { + error!(?e, "sync completed with error"); + last_op_status.store(false, Ordering::Relaxed) + } + }; + } + } + } + info!("Stopped sync driver"); + }); + + // Block on signals now. + loop { + tokio::select! { + Ok(()) = tokio::signal::ctrl_c() => { + break + } + Some(()) = async move { + let sigterm = tokio::signal::unix::SignalKind::terminate(); + tokio::signal::unix::signal(sigterm).unwrap().recv().await + } => { + break + } + Some(()) = async move { + let sigterm = tokio::signal::unix::SignalKind::alarm(); + tokio::signal::unix::signal(sigterm).unwrap().recv().await + } => { + // Ignore + } + Some(()) = async move { + let sigterm = tokio::signal::unix::SignalKind::hangup(); + tokio::signal::unix::signal(sigterm).unwrap().recv().await + } => { + // Ignore + } + Some(()) = async move { + let sigterm = tokio::signal::unix::SignalKind::user_defined1(); + tokio::signal::unix::signal(sigterm).unwrap().recv().await + } => { + // Ignore + } + Some(()) = async move { + let sigterm = tokio::signal::unix::SignalKind::user_defined2(); + tokio::signal::unix::signal(sigterm).unwrap().recv().await + } => { + // Ignore + } + } + } + + broadcast_tx + .send(true) + .expect("Failed to trigger a clean shutdown!"); + + let _ = driver_handle.await; + if let Some(sh) = status_handle { + let _ = sh.await; + } + } else { + if let Err(e) = run_sync(cb, &sync_config, &opt).await { + error!(?e, "Sync completed with error"); + }; + } +} + +async fn status_task( + listener: TcpListener, + mut status_rx: broadcast::Receiver, + last_op_status: Arc, +) { + loop { + tokio::select! { + _ = status_rx.recv() => { + break; + } + maybe_sock = listener.accept() => { + let mut stream = match maybe_sock { + Ok((sock, addr)) => { + debug!("accept from {:?}", addr); + sock + } + Err(e) => { + error!(?e, "Failed to accept status connection"); + continue; + } + }; + + let sr = if last_op_status.load(Ordering::Relaxed) { + stream.write_all(b"Ok\n").await + } else { + stream.write_all(b"Err\n").await + }; + if let Err(e) = sr { + error!(?e, "Failed to send status"); + } + } + } + } + info!("Stopped status task"); +} + +async fn run_sync( + cb: KanidmClientBuilder, + sync_config: &Config, + opt: &Opt, +) -> Result<(), SyncError> { let rsclient = match cb.build() { Ok(rsc) => rsc, Err(_e) => { error!("Failed to build async client"); - return; + return Err(SyncError::ClientConfig); } }; @@ -99,7 +280,6 @@ async fn driver_main(opt: Opt) { // Preflight check. // * can we connect to ipa? - let mut ipa_client = match LdapClientBuilder::new(&sync_config.ipa_uri) .add_tls_ca(&sync_config.ipa_ca) .build() @@ -108,7 +288,7 @@ async fn driver_main(opt: Opt) { Ok(lc) => lc, Err(e) => { error!(?e, "Failed to connect to freeipa"); - return; + return Err(SyncError::LdapConn); } }; @@ -124,7 +304,7 @@ async fn driver_main(opt: Opt) { } Err(e) => { error!(?e, "Failed to bind (authenticate) to freeipa"); - return; + return Err(SyncError::LdapAuth); } }; @@ -134,7 +314,7 @@ async fn driver_main(opt: Opt) { Ok(s) => s, Err(e) => { error!(?e, "Failed to access scim sync status"); - return; + return Err(SyncError::SyncStatus); } }; @@ -184,13 +364,13 @@ async fn driver_main(opt: Opt) { debug!(ipa_sync_base_dn = ?sync_config.ipa_sync_base_dn, ?cookie, ?mode, ?filter); let sync_result = match ipa_client - .syncrepl(sync_config.ipa_sync_base_dn, filter, cookie, mode) + .syncrepl(sync_config.ipa_sync_base_dn.clone(), filter, cookie, mode) .await { Ok(results) => results, Err(e) => { error!(?e, "Failed to perform syncrepl from ipa"); - return; + return Err(SyncError::LdapSyncrepl); } }; @@ -211,7 +391,7 @@ async fn driver_main(opt: Opt) { .await { Ok(ssr) => ssr, - Err(()) => return, + Err(()) => return Err(SyncError::Preprocess), }; if opt.proto_dump { @@ -220,17 +400,21 @@ async fn driver_main(opt: Opt) { if let Err(e) = serde_json::to_writer_pretty(stdout, &scim_sync_request) { error!(?e, "Failed to serialise scim sync request"); }; + Ok(()) } else if opt.dry_run { info!("dry-run complete"); info!("Success!"); + Ok(()) } else { if let Err(e) = rsclient.scim_v1_sync_update(&scim_sync_request).await { error!( ?e, "Failed to submit scim sync update - see the kanidmd server log for more details." ); + Err(SyncError::SyncUpdate) } else { info!("Success!"); + Ok(()) } } // done! diff --git a/iam_migrations/freeipa/src/opt.rs b/iam_migrations/freeipa/src/opt.rs index b84250358..0251dd225 100644 --- a/iam_migrations/freeipa/src/opt.rs +++ b/iam_migrations/freeipa/src/opt.rs @@ -31,6 +31,11 @@ pub struct Opt { #[clap(short = 'n')] pub dry_run: bool, + /// Run in scheduled mode, where the sync tool will periodically attempt to sync between + /// FreeIPA and Kanidm. + #[clap(long = "schedule")] + pub schedule: bool, + /// Skip the root user permission check. #[clap(short, long, hide = true)] pub skip_root_check: bool, diff --git a/kanidm_book/src/SUMMARY.md b/kanidm_book/src/SUMMARY.md index b550fb9d0..b04b2e6e7 100644 --- a/kanidm_book/src/SUMMARY.md +++ b/kanidm_book/src/SUMMARY.md @@ -31,6 +31,11 @@ - [RADIUS](integrations/radius.md) - [LDAP](integrations/ldap.md) +# Synchronisation + +- [Concepts](sync/concepts.md) +- [FreeIPA](sync/freeipa.md) + # Integration Examples - [Kubernetes Ingress](examples/k8s_ingress_example.md) diff --git a/kanidm_book/src/client_tools.md b/kanidm_book/src/client_tools.md index a5ad36e8d..65701b5e0 100644 --- a/kanidm_book/src/client_tools.md +++ b/kanidm_book/src/client_tools.md @@ -1,8 +1,7 @@ # Client tools To interact with Kanidm as an administrator, you'll need to use our command -line tools. If you haven't installed them yet, -[install them now](installing_client_tools.mdc). +line tools. If you haven't installed them yet, [install them now](installing_client_tools.md). ## Kanidm configuration @@ -40,4 +39,4 @@ locally you can use: To log out of a session: kanidm logout --name USERNAME - kanidm logout --name admin \ No newline at end of file + kanidm logout --name admin diff --git a/kanidm_book/src/sync/concepts.md b/kanidm_book/src/sync/concepts.md new file mode 100644 index 000000000..22eec2c36 --- /dev/null +++ b/kanidm_book/src/sync/concepts.md @@ -0,0 +1,117 @@ +# Synchronisation Concepts + +## Introduction + +In some environments Kanidm may be the first Identity Management system introduced. However many +existing environments have existing IDM systems that are well established and in use. To allow +Kanidm to work with these, it is possible to synchronised data between these IDM systems. + +Currently Kanidm can consume (import) data from another IDM system. There are two major use cases +for this: + +* Running Kanidm in parallel with another IDM system +* Migrating from an existing IDM to Kanidm + +An incoming IDM data source is bound to Kanidm by a sync account. All synchronised entries will +have a reference to the sync account that they came from defined by their "sync parent uuid". +While an entry is owned by a sync account we refer to the sync account as having authority over +the content of that entry. + +The sync process is driven by a sync tool. This tool extracts the current state of the sync from +Kanidm, requests the set of changes (differences) from the IDM source, and then submits these +changes to Kanidm. Kanidm will update and apply these changes and commit the new sync state on +success. + +In the event of a conflict or data import error, Kanidm will halt and rollback the synchronisation +to the last good state. The sync tool should be reconfigured to exclude the conflicting entry or +to remap it's properties to resolve the conflict. The operation can then be retried. + +This process can continue long term to allow Kanidm to operate in parallel to another IDM system. If +this is for a migration however, the sync account can be finalised. This terminates the sync account +and removes the sync parent uuid from all synchronised entries, moving authority of the entry into +Kanidm. + +Alternatelly, the sync account can be terminated which removes all synchronised content that was submitted. + +## Creating a Sync Account + +Creating a sync account requires administration permissions. By default this is available to +members of the "system\_admins" group which "admin" is a memberof by default. + + kanidm system sync create + kanidm system sync create ipasync + +Once the sync account is created you can then generate the sync token which identifies the +sync tool. + + kanidm system sync generate-token + kanidm system sync generate-token ipasync mylabel + token: eyJhbGci... + +{{#template + ../templates/kani-warning.md + imagepath=../images + title=Warning! + text=The sync account token has a high level of privilege, able to create new accounts and groups. It should be treated carefully as a result! +}} + +If you need to revoke the token, you can do so with: + + kanidm system sync destroy-token + kanidm system sync destroy-token ipasync + +Destroying the token does NOT affect the state of the sync account and it's synchronised entries. Creating +a new token and providing that to the sync tool will continue the sync process. + +## Operating the Sync Tool + +The sync tool can now be run to replicate entries from the external IDM system into Kanidm. + +You should refer to the chapter for the specific external IDM system you are using for details on +the sync tool configuration. + +The sync tool runs in batches, meaning that changes from the source IDM service will be delayed to +appear into Kanidm. This is affected by how frequently you choose to run the sync tool. + +If the sync tool fails, you can investigate details in the Kanidmd server output. + +The sync tool can run "indefinetly" if you wish for Kanidm to always import data from the external +source. + +## Finalisting the Sync Account + +If you are performing a migration from an external IDM to Kanidm, when that migration is completed +you can nominate that Kanidm now owns all of the imported data. This is achieved by finalising the +sync account. + +{{#template + ../templates/kani-warning.md + imagepath=../images + title=Warning! + text=You can not undo this operation. Once you have finalised an agreement, Kanidm owns all of the synchronised data, and you can not resume synchronisation. +}} + + kanidm system sync finalise + kanidm system sync finalise ipasync + # Do you want to continue? This operation can NOT be undone. [y/N] + +Once finalised, imported accounts can now be fully managed by Kanidm. + +## Terminating the Sync Account + +If you decide to cease importing accounts or need to remove all imported accounts from a sync +account, you can choose to terminate the agreement removing all data that was imported. + +{{#template + ../templates/kani-warning.md + imagepath=../images + title=Warning! + text=You can not undo this operation. Once you have terminated an agreement, Kanidm deletes all of the synchronised data, and you can not resume synchronisation. +}} + + kanidm system sync terminate + kanidm system sync terminate ipasync + # Do you want to continue? This operation can NOT be undone. [y/N] + +Once terminated all imported data will be deleted by Kanidm. + diff --git a/kanidm_book/src/sync/freeipa.md b/kanidm_book/src/sync/freeipa.md new file mode 100644 index 000000000..96882ae79 --- /dev/null +++ b/kanidm_book/src/sync/freeipa.md @@ -0,0 +1,68 @@ +# Synchronising from FreeIPA + +FreeIPA is a popular opensource LDAP and Kerberos provider, aiming to be "Active Directory" for +Linux. + +Kanidm is able to synchronise from FreeIPA for the purposes of coexistence or migration. + +## Installing the FreeIPA Sync Tool + +See [installing the client tools](../installing_client_tools.md). + +## Configure the FreeIPA Sync Tool + +The sync tool is a bridge between FreeIPA and Kanidm, meaning that the tool must be configured to +communicate to both sides. + +Like other components of Kanidm, the FreeIPA sync tool will read your /etc/kanidm/config if present +to understand how to connect to Kanidm. + +The sync tool specific components are configured in it's own configuration file. + +``` +{{#rustdoc_include ../../../examples/kanidm-ipa-sync}} +``` + +This example is located in [examples/kanidm-ipa-sync](https://github.com/kanidm/kanidm/blob/master/examples/kanidm-ipa-sync). + +In addition to this, you must make some configuration changes to FreeIPA to enable synchronisation. + +You must modify the retro changelog plugin to include the full scope of the database suffix. + +``` +{{#rustdoc_include ../../../iam_migrations/freeipa/00config-mod.ldif}} +``` + +You must then restart your FreeIPA server. + +## Running the Sync Tool Manually + +You can perform a dry run with the sync tool manually to check your configurations are +correct. + + kanidm-ipa-sync [-c /path/to/kanidm/config] -i /path/to/kanidm-ipa-sync -n + kanidm-ipa-sync -i /etc/kanidm/ipa-sync -n + +## Running the Sync Tool Automatically + +The sync tool can be run on a schedule if you configure the `schedule` parameter, and provide +the option "--schedule" on the cli + + kanidm-ipa-sync [-c /path/to/kanidm/config] -i /path/to/kanidm-ipa-sync --schedule + +## Monitoring the Sync Tool + +When running in schedule mode, you may wish to monitor the sync tool for failures. Since failures +block the sync process, this is important to ensuring a smooth and reliable synchronisation process. + +You can configure a status listener that can be monitored via tcp with the parameter `status_bind`. + +An example of monitoring this with netcat is: + + # status_bind = "[::1]:12345" + # nc ::1 12345 + Ok + +It's important to note no details are revealed via the status socket, and is purely for Ok or Err status +of the last sync. + diff --git a/kanidm_client/src/sync_account.rs b/kanidm_client/src/sync_account.rs index b1731da5e..f8f0e4e80 100644 --- a/kanidm_client/src/sync_account.rs +++ b/kanidm_client/src/sync_account.rs @@ -63,4 +63,14 @@ impl KanidmClient { self.perform_patch_request(format!("/v1/sync_account/{}", id).as_str(), update_entry) .await } + + pub async fn idm_sync_account_finalise(&self, id: &str) -> Result<(), ClientError> { + self.perform_get_request(format!("/v1/sync_account/{}/_finalise", id).as_str()) + .await + } + + pub async fn idm_sync_account_terminate(&self, id: &str) -> Result<(), ClientError> { + self.perform_get_request(format!("/v1/sync_account/{}/_terminate", id).as_str()) + .await + } } diff --git a/kanidm_tools/Dockerfile b/kanidm_tools/Dockerfile index 1533ac0e6..a002e5647 100644 --- a/kanidm_tools/Dockerfile +++ b/kanidm_tools/Dockerfile @@ -48,11 +48,18 @@ fi WORKDIR /usr/src/kanidm/ # build the CLI RUN if [ -z "${KANIDM_FEATURES}" ]; then \ - cargo build -p kanidm_tools --bin kanidm ${KANIDM_BUILD_OPTIONS} \ + cargo build --bin kanidm ${KANIDM_BUILD_OPTIONS} \ + --target-dir="/usr/src/kanidm/target/" \ + --release; \ + cargo build --bin kanidm-ipa-sync ${KANIDM_BUILD_OPTIONS} \ --target-dir="/usr/src/kanidm/target/" \ --release; \ else \ - cargo build -p kanidm_tools --bin kanidm ${KANIDM_BUILD_OPTIONS} \ + cargo build --bin kanidm ${KANIDM_BUILD_OPTIONS} \ + --target-dir="/usr/src/kanidm/target/" \ + --features="${KANIDM_FEATURES}" \ + --release; \ + cargo build --bin kanidm-ipa-sync ${KANIDM_BUILD_OPTIONS} \ --target-dir="/usr/src/kanidm/target/" \ --features="${KANIDM_FEATURES}" \ --release; \ @@ -69,7 +76,9 @@ RUN zypper install -y timezone busybox-adduser && \ zypper clean -a COPY --from=builder /usr/src/kanidm/target/release/kanidm /sbin/ +COPY --from=builder /usr/src/kanidm/target/release/kanidm-ipa-sync /sbin/ RUN chmod +x /sbin/kanidm +RUN chmod +x /sbin/kanidm-ipa-sync ENV RUST_BACKTRACE 1 RUN adduser -D -H kanidm diff --git a/kanidm_tools/src/cli/synch.rs b/kanidm_tools/src/cli/synch.rs index 7711fa6cb..665d75762 100644 --- a/kanidm_tools/src/cli/synch.rs +++ b/kanidm_tools/src/cli/synch.rs @@ -1,4 +1,5 @@ use crate::SynchOpt; +use dialoguer::Confirm; impl SynchOpt { pub fn debug(&self) -> bool { @@ -8,7 +9,9 @@ impl SynchOpt { SynchOpt::Create { copt, .. } | SynchOpt::GenerateToken { copt, .. } | SynchOpt::DestroyToken { copt, .. } - | SynchOpt::ForceRefresh { copt, .. } => copt.debug, + | SynchOpt::ForceRefresh { copt, .. } + | SynchOpt::Finalise { copt, .. } + | SynchOpt::Terminate { copt, .. } => copt.debug, } } @@ -71,6 +74,40 @@ impl SynchOpt { Err(e) => error!("Error -> {:?}", e), } } + SynchOpt::Finalise { account_id, copt } => { + if !Confirm::new() + .default(false) + .with_prompt("Do you want to continue? This operation can NOT be undone.") + .interact() + .unwrap() + { + info!("No changes were made"); + return; + } + + let client = copt.to_client().await; + match client.idm_sync_account_finalise(account_id).await { + Ok(()) => println!("Success"), + Err(e) => error!("Error -> {:?}", e), + } + } + SynchOpt::Terminate { account_id, copt } => { + if !Confirm::new() + .default(false) + .with_prompt("Do you want to continue? This operation can NOT be undone.") + .interact() + .unwrap() + { + info!("No changes were made"); + return; + } + + let client = copt.to_client().await; + match client.idm_sync_account_terminate(account_id).await { + Ok(()) => println!("Success"), + Err(e) => error!("Error -> {:?}", e), + } + } } } } diff --git a/kanidm_tools/src/opt/kanidm.rs b/kanidm_tools/src/opt/kanidm.rs index da17a2b93..b9151aace 100644 --- a/kanidm_tools/src/opt/kanidm.rs +++ b/kanidm_tools/src/opt/kanidm.rs @@ -759,6 +759,7 @@ pub enum SynchOpt { #[clap(name = "description")] description: Option, }, + /// Generate a bearer token for an IDM sync account #[clap(name = "generate-token")] GenerateToken { #[clap()] @@ -768,6 +769,7 @@ pub enum SynchOpt { #[clap(flatten)] copt: CommonOpt, }, + /// Destroy (revoke) the bearer token for an IDM sync account #[clap(name = "destroy-token")] DestroyToken { #[clap()] @@ -784,7 +786,31 @@ pub enum SynchOpt { account_id: String, #[clap(flatten)] copt: CommonOpt, - } + }, + /// Finalise and remove this sync account. This will transfer all synchronised entries into + /// the authority of Kanidm. This signals the end of a migration from an external IDM into + /// Kanidm. ⚠️ This action can NOT be undone. Once complete, it is most likely + /// that attempting to recreate a sync account from the same IDM will fail due to conflicting + /// entries that Kanidm now owns. + #[clap(name = "finalise")] + Finalise { + #[clap()] + account_id: String, + #[clap(flatten)] + copt: CommonOpt, + }, + /// Terminate and remove this sync account. This will DELETE all entries that were imported + /// from the external IDM source. ⚠️ This action can NOT be undone, and will require you to + /// recreate the sync account if you + /// wish to re-import data. Recreating the sync account may fail until the recycle bin and + /// and tombstones are purged. + #[clap(name = "terminate")] + Terminate { + #[clap()] + account_id: String, + #[clap(flatten)] + copt: CommonOpt, + }, } #[derive(Debug, Subcommand)] @@ -807,7 +833,8 @@ pub enum SystemOpt { #[clap(subcommand)] commands: DomainOpt, }, - #[clap(name = "sync", hide = true)] + #[clap(name = "sync")] + /// Configure synchronisation from an external IDM system Synch { #[clap(subcommand)] commands: SynchOpt, diff --git a/kanidmd/core/src/actors/v1_scim.rs b/kanidmd/core/src/actors/v1_scim.rs index f7c67eda0..5bbb4e4d6 100644 --- a/kanidmd/core/src/actors/v1_scim.rs +++ b/kanidmd/core/src/actors/v1_scim.rs @@ -1,7 +1,9 @@ use kanidmd_lib::prelude::*; use crate::{QueryServerReadV1, QueryServerWriteV1}; -use kanidmd_lib::idm::scim::{GenerateScimSyncTokenEvent, ScimSyncUpdateEvent}; +use kanidmd_lib::idm::scim::{ + GenerateScimSyncTokenEvent, ScimSyncFinaliseEvent, ScimSyncTerminateEvent, ScimSyncUpdateEvent, +}; use kanidmd_lib::idm::server::IdmServerTransaction; use kanidm_proto::scim_v1::{ScimSyncRequest, ScimSyncState}; @@ -80,6 +82,76 @@ impl QueryServerWriteV1 { .and_then(|r| idms_prox_write.commit().map(|_| r)) } + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn handle_sync_account_finalise( + &self, + uat: Option, + uuid_or_name: String, + eventid: Uuid, + ) -> Result<(), OperationError> { + let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await; + let ident = idms_prox_write + .validate_and_parse_token_to_ident(uat.as_deref(), ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity"); + e + })?; + + let target = idms_prox_write + .qs_write + .name_to_uuid(uuid_or_name.as_str()) + .map_err(|e| { + admin_error!(err = ?e, "Error resolving id to target"); + e + })?; + + let sfe = ScimSyncFinaliseEvent { ident, target }; + + idms_prox_write + .scim_sync_finalise(&sfe) + .and_then(|r| idms_prox_write.commit().map(|_| r)) + } + + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn handle_sync_account_terminate( + &self, + uat: Option, + uuid_or_name: String, + eventid: Uuid, + ) -> Result<(), OperationError> { + let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await; + let ident = idms_prox_write + .validate_and_parse_token_to_ident(uat.as_deref(), ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity"); + e + })?; + + let target = idms_prox_write + .qs_write + .name_to_uuid(uuid_or_name.as_str()) + .map_err(|e| { + admin_error!(err = ?e, "Error resolving id to target"); + e + })?; + + let ste = ScimSyncTerminateEvent { ident, target }; + + idms_prox_write + .scim_sync_terminate(&ste) + .and_then(|r| idms_prox_write.commit().map(|_| r)) + } + #[instrument( level = "info", skip_all, diff --git a/kanidmd/core/src/https/v1_scim.rs b/kanidmd/core/src/https/v1_scim.rs index 57a8713c6..c2985117a 100644 --- a/kanidmd/core/src/https/v1_scim.rs +++ b/kanidmd/core/src/https/v1_scim.rs @@ -4,9 +4,7 @@ use kanidm_proto::scim_v1::ScimSyncRequest; use kanidm_proto::v1::Entry as ProtoEntry; use kanidmd_lib::prelude::*; -use super::v1::{ - json_rest_event_delete_id, json_rest_event_get, json_rest_event_get_id, json_rest_event_post, -}; +use super::v1::{json_rest_event_get, json_rest_event_get_id, json_rest_event_post}; pub async fn sync_account_get(req: tide::Request) -> tide::Result { let filter = filter_all!(f_eq("class", PartialValue::new_class("sync_account"))); @@ -23,11 +21,6 @@ pub async fn sync_account_id_get(req: tide::Request) -> tide::Result { json_rest_event_get_id(req, filter, None).await } -pub async fn sync_account_id_delete(req: tide::Request) -> tide::Result { - let filter = filter_all!(f_eq("class", PartialValue::new_class("sync_account"))); - json_rest_event_delete_id(req, filter).await -} - pub async fn sync_account_id_patch(mut req: tide::Request) -> tide::Result { // Update a value / attrs let uat = req.get_current_uat(); @@ -48,8 +41,7 @@ pub async fn sync_account_id_patch(mut req: tide::Request) -> tide::Re to_tide_response(res, hvalue) } -/* -pub async fn sync_account_token_get(req: tide::Request) -> tide::Result { +pub async fn sync_account_id_get_finalise(req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; @@ -57,12 +49,25 @@ pub async fn sync_account_token_get(req: tide::Request) -> tide::Resul let res = req .state() - .qe_r_ref - .handle_service_account_api_token_get(uat, uuid_or_name, eventid) + .qe_w_ref + .handle_sync_account_finalise(uat, uuid_or_name, eventid) + .await; + to_tide_response(res, hvalue) +} + +pub async fn sync_account_id_get_terminate(req: tide::Request) -> tide::Result { + let uat = req.get_current_uat(); + let uuid_or_name = req.get_url_param("id")?; + + let (eventid, hvalue) = req.new_eventid(); + + let res = req + .state() + .qe_w_ref + .handle_sync_account_terminate(uat, uuid_or_name, eventid) .await; to_tide_response(res, hvalue) } -*/ pub async fn sync_account_token_post(mut req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); @@ -257,8 +262,15 @@ pub fn scim_route_setup(appserver: &mut tide::Route<'_, AppState>, routemap: &mu sync_account_route .at("/:id") .mapped_get(routemap, sync_account_id_get) - .mapped_patch(routemap, sync_account_id_patch) - .mapped_delete(routemap, sync_account_id_delete); + .mapped_patch(routemap, sync_account_id_patch); + + sync_account_route + .at("/:id/_finalise") + .mapped_get(routemap, sync_account_id_get_finalise); + + sync_account_route + .at("/:id/_terminate") + .mapped_get(routemap, sync_account_id_get_terminate); sync_account_route .at("/:id/_sync_token") diff --git a/kanidmd/core/src/interval.rs b/kanidmd/core/src/interval.rs index e690d887b..688e80279 100644 --- a/kanidmd/core/src/interval.rs +++ b/kanidmd/core/src/interval.rs @@ -5,8 +5,10 @@ use std::fs; use std::path::Path; use chrono::Utc; + use saffron::parse::{CronExpr, English}; use saffron::Cron; + use tokio::sync::broadcast; use tokio::time::{interval, sleep, Duration}; diff --git a/kanidmd/lib/src/access.rs b/kanidmd/lib/src/access.rs index 4aeec9653..72fbc1600 100644 --- a/kanidmd/lib/src/access.rs +++ b/kanidmd/lib/src/access.rs @@ -366,6 +366,7 @@ pub struct AccessEffectivePermission { // I don't think we need this? The ident is implied by the requestor. // ident: Uuid, pub target: Uuid, + pub delete: bool, pub search: BTreeSet, pub modify_pres: BTreeSet, pub modify_rem: BTreeSet, @@ -538,41 +539,41 @@ pub trait AccessControlsTransaction<'a> { // For each entry let allowed_entries: Vec> = - entries - .into_iter() - .filter(|e| { - // For each acp - let allowed_attrs: BTreeSet<&str> = related_acp - .iter() - .filter_map(|(acs, f_res)| { - // if it applies - if e.entry_match_no_index(f_res) { - security_access!(entry = ?e.get_uuid(), acs = %acs.acp.name, "entry matches acs"); - // add search_attrs to allowed. - Some(acs.attrs.iter().map(|s| s.as_str())) - } else { - trace!(entry = ?e.get_uuid(), acs = %acs.acp.name, "entry DOES NOT match acs"); // should this be `security_access`? - None - } - }) - .flatten() - .collect(); + entries + .into_iter() + .filter(|e| { + // For each acp + let allowed_attrs: BTreeSet<&str> = related_acp + .iter() + .filter_map(|(acs, f_res)| { + // if it applies + if e.entry_match_no_index(f_res) { + security_access!(entry = ?e.get_uuid(), acs = %acs.acp.name, "entry matches acs"); + // add search_attrs to allowed. + Some(acs.attrs.iter().map(|s| s.as_str())) + } else { + trace!(entry = ?e.get_uuid(), acs = %acs.acp.name, "entry DOES NOT match acs"); // should this be `security_access`? + None + } + }) + .flatten() + .collect(); - security_access!( - requested = ?requested_attrs, - allows = ?allowed_attrs, - "attributes", - ); + security_access!( + requested = ?requested_attrs, + allows = ?allowed_attrs, + "attributes", + ); - // is attr set a subset of allowed set? - // true -> entry is allowed in result set - // false -> the entry is not allowed to be searched by this entity, so is - // excluded. - let decision = requested_attrs.is_subset(&allowed_attrs); - security_access!(?decision, "search attr decision"); - decision - }) - .collect(); + // is attr set a subset of allowed set? + // true -> entry is allowed in result set + // false -> the entry is not allowed to be searched by this entity, so is + // excluded. + let decision = requested_attrs.is_subset(&allowed_attrs); + security_access!(?decision, "search attr decision"); + decision + }) + .collect(); if allowed_entries.is_empty() { security_access!("denied ❌"); @@ -1104,6 +1105,44 @@ pub trait AccessControlsTransaction<'a> { Ok(r) } + #[instrument(level = "debug", name = "access::delete_related_acp", skip_all)] + fn delete_related_acp<'b>( + &'b self, + ident: &Identity, + ) -> Vec<(&'b AccessControlDelete, Filter)> { + // Some useful references we'll use for the remainder of the operation + let delete_state = self.get_delete(); + let acp_resolve_filter_cache = self.get_acp_resolve_filter_cache(); + + let related_acp: Vec<(&AccessControlDelete, _)> = delete_state + .iter() + .filter_map(|acs| { + if let Some(receiver) = acs.acp.receiver { + if ident.is_memberof(receiver) { + acs.acp + .targetscope + .resolve(ident, None, Some(acp_resolve_filter_cache)) + .map_err(|e| { + admin_error!( + "A internal filter/event was passed for resolution!?!? {:?}", + e + ); + e + }) + .ok() + .map(|f_res| (acs, f_res)) + } else { + None + } + } else { + None + } + }) + .collect(); + + related_acp + } + #[instrument(level = "debug", name = "access::delete_allow_operation", skip_all)] fn delete_allow_operation( &self, @@ -1134,36 +1173,8 @@ pub trait AccessControlsTransaction<'a> { } }; - // Some useful references we'll use for the remainder of the operation - let delete_state = self.get_delete(); - let acp_resolve_filter_cache = self.get_acp_resolve_filter_cache(); - // Find the acps that relate to the caller. - let related_acp: Vec<(&AccessControlDelete, _)> = delete_state - .iter() - .filter_map(|acs| { - if let Some(receiver) = acs.acp.receiver { - if de.ident.is_memberof(receiver) { - acs.acp - .targetscope - .resolve(&de.ident, None, Some(acp_resolve_filter_cache)) - .map_err(|e| { - admin_error!( - "A internal filter/event was passed for resolution!?!? {:?}", - e - ); - e - }) - .ok() - .map(|f_res| (acs, f_res)) - } else { - None - } - } else { - None - } - }) - .collect(); + let related_acp = self.delete_related_acp(&de.ident); /* related_acp.iter().for_each(|racp| { @@ -1256,7 +1267,8 @@ pub trait AccessControlsTransaction<'a> { // == modify == - let modify_related_acp: Vec<(&AccessControlModify, _)> = self.modify_related_acp(ident); + let modify_related_acp = self.modify_related_acp(ident); + let delete_related_acp = self.delete_related_acp(ident); /* modify_related_acp.iter().for_each(|(racp, _)| { @@ -1323,8 +1335,23 @@ pub trait AccessControlsTransaction<'a> { .flat_map(|acp| acp.classes.iter().cloned()) .collect(); + // == delete == + let delete = delete_related_acp.iter().any(|(acd, f_res)| { + if e.entry_match_no_index(f_res) { + security_access!( + entry_uuid = ?e.get_uuid(), + acs = %acd.acp.name, + "entry matches acd" + ); + true + } else { + false + } + }); + AccessEffectivePermission { target: e.get_uuid(), + delete, search: search_effective, modify_pres, modify_rem, @@ -2859,6 +2886,7 @@ mod tests { vec![], &r_set, vec![AccessEffectivePermission { + delete: false, target: uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930"), search: btreeset![AttrString::from("name")], modify_pres: BTreeSet::new(), @@ -2899,6 +2927,7 @@ mod tests { }], &r_set, vec![AccessEffectivePermission { + delete: false, target: uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930"), search: BTreeSet::new(), modify_pres: btreeset![AttrString::from("name")], diff --git a/kanidmd/lib/src/idm/scim.rs b/kanidmd/lib/src/idm/scim.rs index 03b7dacef..a569ca3ff 100644 --- a/kanidmd/lib/src/idm/scim.rs +++ b/kanidmd/lib/src/idm/scim.rs @@ -12,6 +12,7 @@ use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTrans use crate::prelude::*; use crate::value::Session; +use crate::access::AccessControlsTransaction; use crate::schema::{SchemaClass, SchemaTransaction}; // Internals of a Scim Sync token @@ -227,6 +228,257 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } } +pub struct ScimSyncFinaliseEvent { + pub ident: Identity, + pub target: Uuid, +} + +impl<'a> IdmServerProxyWriteTransaction<'a> { + pub fn scim_sync_finalise( + &mut self, + sfe: &ScimSyncFinaliseEvent, + ) -> Result<(), OperationError> { + // Get the target and ensure it's really a sync account + let entry = self + .qs_write + .internal_search_uuid(sfe.target) + .map_err(|e| { + admin_error!(?e, "Failed to search sync account"); + e + })?; + + let sync_account = SyncAccount::try_from_entry_rw(&entry).map_err(|e| { + admin_error!(?e, "Failed to covert sync account"); + e + })?; + let sync_uuid = sync_account.uuid; + + // Do we have permission to delete it? + let effective_perms = self + .qs_write + .get_accesscontrols() + .effective_permission_check(&sfe.ident, Some(BTreeSet::default()), &[entry])?; + + let eperm = effective_perms.get(0).ok_or_else(|| { + admin_error!("Effective Permission check returned no results"); + OperationError::InvalidState + })?; + + if eperm.target != sync_account.uuid { + admin_error!("Effective Permission check target differs from requested entry uuid"); + return Err(OperationError::InvalidEntryState); + } + + // ⚠️ Assume that anything before this line is unauthorised, and after this line IS + // authorised! + // + // We do this check via effective permissions because a lot of the operations that + // follow will require permissions beyond what system admins have. + + if !eperm.delete { + security_info!( + "Requestor {} does not have permission to delete sync account {}", + sfe.ident, + sync_account.name + ); + return Err(OperationError::NotAuthorised); + } + + // Referential integrity tries to assert that the reference to sync_parent_uuid is valid + // from within the recycle bin. To prevent this, we have to "finalise" first, transfer + // authority to kanidm, THEN we do the delete which breaks the reference requirement. + // + // Importantly, we have to do this for items that are in the recycle bin! + + // First, get the set of uuids that exist. We need this so we have the set of uuids we'll + // be deleteing *at the end*. + let f_all_sync = filter_all!(f_and!([ + f_eq("class", PVCLASS_SYNC_OBJECT.clone()), + f_eq("sync_parent_uuid", PartialValue::Refer(sync_uuid)) + ])); + + // TODO: This could benefit from a search that only grabs uuids? + let existing_entries = self + .qs_write + .internal_search(f_all_sync.clone()) + .map_err(|e| { + error!("Failed to determine existing entries set"); + e + })?; + + // This is the delete filter we need later. + let filter_or: Vec<_> = existing_entries + .iter() + .map(|e| f_eq("uuid", PartialValue::Uuid(e.get_uuid()))) + .collect(); + + // We only need to delete the sync account itself. + let delete_filter = filter!(f_eq("uuid", PartialValue::Uuid(sync_uuid))); + + if !filter_or.is_empty() { + // Now modify these to remove their sync related attributes. + let schema = self.qs_write.get_schema(); + let sync_class = schema.get_classes().get("sync_object").ok_or_else(|| { + error!("Failed to access sync_object class, schema corrupt"); + OperationError::InvalidState + })?; + + let modlist = std::iter::once(Modify::Removed( + "class".into(), + PartialValue::new_class("sync_object"), + )) + .chain( + sync_class + .may_iter() + .map(|aname| Modify::Purged(aname.clone())), + ) + .collect(); + + let mods = ModifyList::new_list(modlist); + + self.qs_write + .internal_modify(&f_all_sync, &mods) + .map_err(|e| { + error!("Failed to modify sync objects to grant authority to kanidm"); + e + })?; + }; + + self.qs_write.internal_delete(&delete_filter).map_err(|e| { + error!(?e, "Failed to terminate sync account"); + e + }) + } +} + +pub struct ScimSyncTerminateEvent { + pub ident: Identity, + pub target: Uuid, +} + +impl<'a> IdmServerProxyWriteTransaction<'a> { + pub fn scim_sync_terminate( + &mut self, + ste: &ScimSyncTerminateEvent, + ) -> Result<(), OperationError> { + // Get the target and ensure it's really a sync account + let entry = self + .qs_write + .internal_search_uuid(ste.target) + .map_err(|e| { + admin_error!(?e, "Failed to search sync account"); + e + })?; + + let sync_account = SyncAccount::try_from_entry_rw(&entry).map_err(|e| { + admin_error!(?e, "Failed to covert sync account"); + e + })?; + let sync_uuid = sync_account.uuid; + + // Do we have permission to delete it? + let effective_perms = self + .qs_write + .get_accesscontrols() + .effective_permission_check(&ste.ident, Some(BTreeSet::default()), &[entry])?; + + let eperm = effective_perms.get(0).ok_or_else(|| { + admin_error!("Effective Permission check returned no results"); + OperationError::InvalidState + })?; + + if eperm.target != sync_account.uuid { + admin_error!("Effective Permission check target differs from requested entry uuid"); + return Err(OperationError::InvalidEntryState); + } + + // ⚠️ Assume that anything before this line is unauthorised, and after this line IS + // authorised! + // + // We do this check via effective permissions because a lot of the operations that + // follow will require permissions beyond what system admins have. + + if !eperm.delete { + security_info!( + "Requestor {} does not have permission to delete sync account {}", + ste.ident, + sync_account.name + ); + return Err(OperationError::NotAuthorised); + } + + // Referential integrity tries to assert that the reference to sync_parent_uuid is valid + // from within the recycle bin. To prevent this, we have to "finalise" first, transfer + // authority to kanidm, THEN we do the delete which breaks the reference requirement. + // + // Importantly, we have to do this for items that are in the recycle bin! + + // First, get the set of uuids that exist. We need this so we have the set of uuids we'll + // be deleteing *at the end*. + let f_all_sync = filter_all!(f_and!([ + f_eq("class", PVCLASS_SYNC_OBJECT.clone()), + f_eq("sync_parent_uuid", PartialValue::Refer(sync_uuid)) + ])); + + // TODO: This could benefit from a search that only grabs uuids? + let existing_entries = self + .qs_write + .internal_search(f_all_sync.clone()) + .map_err(|e| { + error!("Failed to determine existing entries set"); + e + })?; + + // This is the delete filter we need later. + let filter_or: Vec<_> = existing_entries + .iter() + .map(|e| f_eq("uuid", PartialValue::Uuid(e.get_uuid()))) + .collect(); + + let delete_filter = if filter_or.is_empty() { + // We only need to delete the sync account itself. + filter!(f_eq("uuid", PartialValue::Uuid(sync_uuid))) + } else { + // Now modify these to remove their sync related attributes. + let schema = self.qs_write.get_schema(); + let sync_class = schema.get_classes().get("sync_object").ok_or_else(|| { + error!("Failed to access sync_object class, schema corrupt"); + OperationError::InvalidState + })?; + + let modlist = std::iter::once(Modify::Removed( + "class".into(), + PartialValue::new_class("sync_object"), + )) + .chain( + sync_class + .may_iter() + .map(|aname| Modify::Purged(aname.clone())), + ) + .collect(); + + let mods = ModifyList::new_list(modlist); + + self.qs_write + .internal_modify(&f_all_sync, &mods) + .map_err(|e| { + error!("Failed to modify sync objects to grant authority to kanidm"); + e + })?; + + filter!(f_or!([ + f_eq("uuid", PartialValue::Uuid(sync_uuid)), + f_or(filter_or) + ])) + }; + + self.qs_write.internal_delete(&delete_filter).map_err(|e| { + error!(?e, "Failed to terminate sync account"); + e + }) + } +} + pub struct ScimSyncUpdateEvent { pub ident: Identity, } @@ -991,7 +1243,10 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use super::{GenerateScimSyncTokenEvent, ScimSyncToken, ScimSyncUpdateEvent}; + use super::{ + GenerateScimSyncTokenEvent, ScimSyncFinaliseEvent, ScimSyncTerminateEvent, ScimSyncToken, + ScimSyncUpdateEvent, + }; use async_std::task; @@ -2042,6 +2297,225 @@ mod tests { }) } + #[test] + fn test_idm_scim_sync_finalise_1() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct); + let sse = ScimSyncUpdateEvent { ident }; + + let changes = + serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync"); + + assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok()); + + assert!(idms_prox_write.commit().is_ok()); + + // Finalise the sync account. + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + + let ident = idms_prox_write + .qs_write + .internal_search_uuid(UUID_ADMIN) + .map(Identity::from_impersonate_entry_readwrite) + .expect("Failed to get admin"); + + let sfe = ScimSyncFinaliseEvent { + ident, + target: sync_uuid, + }; + + idms_prox_write + .scim_sync_finalise(&sfe) + .expect("Failed to finalise sync account"); + + // Check that the entries still exists but now have no sync_object attached. + let testgroup = get_single_entry("testgroup", &mut idms_prox_write); + assert!(!testgroup.attribute_equality("class", &PVCLASS_SYNC_OBJECT)); + + let testposix = get_single_entry("testposix", &mut idms_prox_write); + assert!(!testposix.attribute_equality("class", &PVCLASS_SYNC_OBJECT)); + + let testexternal = get_single_entry("testexternal", &mut idms_prox_write); + assert!(!testexternal.attribute_equality("class", &PVCLASS_SYNC_OBJECT)); + + let testuser = get_single_entry("testuser", &mut idms_prox_write); + assert!(!testuser.attribute_equality("class", &PVCLASS_SYNC_OBJECT)); + + assert!(idms_prox_write.commit().is_ok()); + }) + } + + #[test] + fn test_idm_scim_sync_finalise_2() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct); + let sse = ScimSyncUpdateEvent { ident }; + + let changes = + serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync"); + + assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok()); + + // The difference in this test is that the refresh deletes some entries + // so the recycle bin case needs to be handled. + let changes = serde_json::from_str(TEST_SYNC_SCIM_IPA_REFRESH_1) + .expect("failed to parse scim sync"); + + assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok()); + + assert!(idms_prox_write.commit().is_ok()); + + // Finalise the sync account. + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + + let ident = idms_prox_write + .qs_write + .internal_search_uuid(UUID_ADMIN) + .map(Identity::from_impersonate_entry_readwrite) + .expect("Failed to get admin"); + + let sfe = ScimSyncFinaliseEvent { + ident, + target: sync_uuid, + }; + + idms_prox_write + .scim_sync_finalise(&sfe) + .expect("Failed to finalise sync account"); + + // Check that the entries still exists but now have no sync_object attached. + let testgroup = get_single_entry("testgroup", &mut idms_prox_write); + assert!(!testgroup.attribute_equality("class", &PVCLASS_SYNC_OBJECT)); + + let testuser = get_single_entry("testuser", &mut idms_prox_write); + assert!(!testuser.attribute_equality("class", &PVCLASS_SYNC_OBJECT)); + + for iname in ["testposix", "testexternal"] { + trace!(%iname); + assert!(idms_prox_write + .qs_write + .internal_search(filter!(f_eq("name", PartialValue::new_iname(iname)))) + .unwrap() + .is_empty()); + } + + assert!(idms_prox_write.commit().is_ok()); + }) + } + + #[test] + fn test_idm_scim_sync_terminate_1() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct); + let sse = ScimSyncUpdateEvent { ident }; + + let changes = + serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync"); + + assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok()); + + assert!(idms_prox_write.commit().is_ok()); + + // Terminate the sync account + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + + let ident = idms_prox_write + .qs_write + .internal_search_uuid(UUID_ADMIN) + .map(Identity::from_impersonate_entry_readwrite) + .expect("Failed to get admin"); + + let sfe = ScimSyncTerminateEvent { + ident, + target: sync_uuid, + }; + + idms_prox_write + .scim_sync_terminate(&sfe) + .expect("Failed to terminate sync account"); + + // Check that the entries no longer exist + for iname in ["testgroup", "testposix", "testexternal", "testuser"] { + trace!(%iname); + assert!(idms_prox_write + .qs_write + .internal_search(filter!(f_eq("name", PartialValue::new_iname(iname)))) + .unwrap() + .is_empty()); + } + + assert!(idms_prox_write.commit().is_ok()); + }) + } + + #[test] + fn test_idm_scim_sync_terminate_2() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct); + let sse = ScimSyncUpdateEvent { ident }; + + let changes = + serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync"); + + assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok()); + + // The difference in this test is that the refresh deletes some entries + // so the recycle bin case needs to be handled. + let changes = serde_json::from_str(TEST_SYNC_SCIM_IPA_REFRESH_1) + .expect("failed to parse scim sync"); + + assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok()); + + assert!(idms_prox_write.commit().is_ok()); + + // Terminate the sync account + let mut idms_prox_write = task::block_on(idms.proxy_write(ct)); + + let ident = idms_prox_write + .qs_write + .internal_search_uuid(UUID_ADMIN) + .map(Identity::from_impersonate_entry_readwrite) + .expect("Failed to get admin"); + + let sfe = ScimSyncTerminateEvent { + ident, + target: sync_uuid, + }; + + idms_prox_write + .scim_sync_terminate(&sfe) + .expect("Failed to terminate sync account"); + + // Check that the entries no longer exist + for iname in ["testgroup", "testposix", "testexternal", "testuser"] { + trace!(%iname); + assert!(idms_prox_write + .qs_write + .internal_search(filter!(f_eq("name", PartialValue::new_iname(iname)))) + .unwrap() + .is_empty()); + } + + assert!(idms_prox_write.commit().is_ok()); + }) + } + const TEST_SYNC_SCIM_IPA_1: &str = r#" { "from_state": "Refresh", diff --git a/kanidmd/lib/src/schema.rs b/kanidmd/lib/src/schema.rs index bce85d601..052c6c1ec 100644 --- a/kanidmd/lib/src/schema.rs +++ b/kanidmd/lib/src/schema.rs @@ -1672,7 +1672,6 @@ impl<'a> SchemaWriteTransaction<'a> { uuid: UUID_SCHEMA_CLASS_SYNC_OBJECT, description: String::from("A class denoting that an entry is synchronised from an external source. This entry may not be modifiable."), systemmust: vec![ - AttrString::from("uuid"), AttrString::from("sync_parent_uuid") ], systemmay: vec![ diff --git a/kanidmd/lib/src/server/batch_modify.rs b/kanidmd/lib/src/server/batch_modify.rs index fa93d254c..aa676371a 100644 --- a/kanidmd/lib/src/server/batch_modify.rs +++ b/kanidmd/lib/src/server/batch_modify.rs @@ -131,7 +131,14 @@ impl<'a> QueryServerWriteTransaction<'a> { .collect::, _>>()?; // Did any of the candidates now become masked? - if candidates.iter().any(|e| e.mask_recycled_ts().is_none()) { + if std::iter::zip( + pre_candidates + .iter() + .map(|e| e.mask_recycled_ts().is_none()), + candidates.iter().map(|e| e.mask_recycled_ts().is_none()), + ) + .any(|(a, b)| a != b) + { admin_warn!("Refusing to apply modifications that are attempting to bypass replication state machine."); return Err(OperationError::AccessDenied); } diff --git a/kanidmd/lib/src/server/mod.rs b/kanidmd/lib/src/server/mod.rs index a39e686d3..8c13151cf 100644 --- a/kanidmd/lib/src/server/mod.rs +++ b/kanidmd/lib/src/server/mod.rs @@ -1809,7 +1809,14 @@ impl<'a> QueryServerWriteTransaction<'a> { trace!("modify: candidates -> {:?}", candidates); // Did any of the candidates now become masked? - if candidates.iter().any(|e| e.mask_recycled_ts().is_none()) { + if std::iter::zip( + pre_candidates + .iter() + .map(|e| e.mask_recycled_ts().is_none()), + candidates.iter().map(|e| e.mask_recycled_ts().is_none()), + ) + .any(|(a, b)| a != b) + { admin_warn!("Refusing to apply modifications that are attempting to bypass replication state machine."); return Err(OperationError::AccessDenied); }