1496 ldap basedn config ()

This commit is contained in:
Firstyear 2023-03-29 09:34:43 +10:00 committed by GitHub
parent 87d9f74d83
commit c1f62674f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 250 additions and 74 deletions
book/src/integrations
libs/client/src
server/lib/src
tools/cli/src

View file

@ -39,24 +39,24 @@ tree. Kanidm is a flat model, so we have to emulate some tree-like elements, and
For this reason, when you search the LDAP interface, Kanidm will make some mapping decisions.
- The Kanidm domain name is used to generate the DN of the suffix.
- The Kanidm domain name is used to generate the DN of the suffix by default.
- The domain\_info object becomes the suffix root.
- All other entries are direct subordinates of the domain\_info for DN purposes.
- Distinguished Names (DNs) are generated from the spn, name, or uuid attribute.
- Bind DNs can be remapped and rewritten, and may not even be a DN during bind.
- The '\*' and '+' operators can not be used in conjunction with attribute lists in searches.
These decisions were made to make the path as simple and effective as possible, relying more on the
Kanidm query and filter system than attempting to generate a tree-like representation of data. As
almost all clients can use filters for entry selection we don't believe this is a limitation for the
consuming applications.
Kanidm query and filter system rather than attempting to generate a tree-like representation of
data. As almost all clients can use filters for entry selection we don't believe this is a
limitation for the consuming applications.
## Security
### TLS
StartTLS is not supported due to security risks. LDAPS is the only secure method of communicating to
any LDAP server. Kanidm will use it's certificates for both HTTPS and LDAPS.
StartTLS is not supported due to security risks such as credential leakage and MITM attacks that are
fundamental in how StartTLS works and can not be repaired. LDAPS is the only secure method of
communicating to any LDAP server. Kanidm will use it's certificates for both HTTPS and LDAPS.
### Writes
@ -70,11 +70,16 @@ bind for any DN will use its configured posix password.
As the POSIX password is not equivalent in strength to the primary credentials of Kanidm (which in
most cases is multi-factor authentication), the LDAP bind does not grant rights to elevated read
permissions. All binds have the permissions of "Anonymous" even if the anonymous account is locked.
permissions. All binds have the permissions of "anonymous" even if the anonymous account is locked.
The exception is service accounts which can use api-tokens during an LDAP bind for elevated read
permissions.
### Filtering Objects
It is recommended that client applications filter accounts that can authenticate with
`(class=account)` and groups with `(class=group)`.
## Server Configuration
To configure Kanidm to provide LDAP, add the argument to the `server.toml` configuration:
@ -102,6 +107,7 @@ To show what attribute maps exists for an entry you can use the attribute search
```bash
# To show Kanidm attributes
ldapsearch ... -x '(name=admin)' '*'
# To show all attribute maps
ldapsearch ... -x '(name=admin)' '+'
```
@ -113,6 +119,11 @@ Kanidm native attributes.
ldapsearch ... -x '(name=admin)' cn objectClass displayname memberof
```
## Group Memberships
Group membership is defined in rfc2307bis or Active Directory style. This means groups are
determined from the "memberof" attribute which contains a DN to a group.
## Service Accounts
If you have
@ -131,7 +142,38 @@ ldapwhoami -H ldaps://idm.example.com -x -D "dn=token" -w "..."
# u: demo_service@idm.example.com
```
## Example
## Changing the Basedn
By default the basedn of the LDAP server is derived from the domain name. For example a domain name
of `idm.example.com` will become `dc=idm,dc=example,dc=com`.
However, you may wish to change this to something shorter or at a higher level within your domain
name.
<!-- deno-fmt-ignore-start -->
{{#template ../templates/kani-warning.md
imagepath=../images
title=Warning!
text=Changing the LDAP Basedn will require you to reconfigure your client applications so they search the correct basedn. Be careful when changing this value!
}}
<!-- deno-fmt-ignore-end -->
As an admin you can change the domain ldap basedn with:
```bash
kanidm system domain set-ldap-basedn <new basedn>
kanidm system domain set-ldap-basedn o=kanidm -D admin
```
Basedns are validated to ensure they are either `dc=`, `ou=` or `o=`. They must have one or more of
these components and must only contain alphanumeric characters.
After the basedn is changed, the new value will take effect after a server restart. If you have a
replicated topology, you must restart all servers.
## Examples
Given a default install with domain "idm.example.com" the configured LDAP DN will be
"dc=idm,dc=example,dc=com".
@ -162,11 +204,6 @@ spn: test1@idm.example.com
entryuuid: 22a65b6c-80c8-4e1a-9b76-3f3afdff8400
```
It is recommended that client applications filter accounts that can login with `(class=account)` and
groups with `(class=group)`. If possible, group membership is defined in RFC2307bis or Active
Directory style. This means groups are determined from the "memberof" attribute which contains a DN
to a group.
LDAP binds can use any unique identifier of the account. The following are all valid bind DNs for
the object listed above (if it was a POSIX account, that is).
@ -180,6 +217,10 @@ ldapwhoami ... -x -D 'spn=test1@idm.example.com,dc=idm,dc=example,dc=com'
ldapwhoami ... -x -D 'name=test1,dc=idm,dc=example,dc=com'
```
## Troubleshooting
### Can't contact LDAP Server (-1)
Most LDAP clients are very picky about TLS, and can be very hard to debug or display errors. For
example these commands:

View file

@ -1704,6 +1704,14 @@ impl KanidmClient {
.await
}
pub async fn idm_domain_set_ldap_basedn(&self, new_basedn: &str) -> Result<(), ClientError> {
self.perform_put_request(
"/v1/domain/_attr/domain_ldap_basedn",
vec![new_basedn.to_string()],
)
.await
}
pub async fn idm_domain_get_ssid(&self) -> Result<String, ClientError> {
self.perform_get_request("/v1/domain/_attr/domain_ssid")
.await

View file

@ -1151,48 +1151,6 @@ lazy_static! {
);
}
// 28 - domain admins acp
pub const JSON_IDM_ACP_DOMAIN_ADMIN_PRIV_V1: &str = r#"{
"attrs": {
"class": [
"object",
"access_control_profile",
"access_control_search",
"access_control_modify"
],
"name": ["idm_acp_domain_admin_priv"],
"uuid": ["00000000-0000-0000-0000-ffffff000026"],
"description": ["Builtin IDM Control for granting domain info administration locally"],
"acp_receiver": [],
"acp_receiver_group": ["00000000-0000-0000-0000-000000000020"],
"acp_targetscope": [
"{\"and\": [{\"eq\": [\"uuid\",\"00000000-0000-0000-0000-ffffff000025\"]}, {\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}"
],
"acp_search_attr": [
"domain_display_name",
"domain_name",
"domain_ssid",
"domain_uuid",
"es256_private_key_der",
"fernet_private_key_str",
"cookie_private_key",
"name",
"uuid"
],
"acp_modify_removedattr": [
"domain_display_name",
"domain_ssid",
"es256_private_key_der",
"cookie_private_key",
"fernet_private_key_str"
],
"acp_modify_presentattr": [
"domain_display_name",
"domain_ssid"
]
}
}"#;
lazy_static! {
pub static ref E_IDM_ACP_DOMAIN_ADMIN_PRIV_V1: EntryInitNew = entry_init!(
("class", CLASS_OBJECT.clone()),
@ -1219,6 +1177,7 @@ lazy_static! {
("acp_search_attr", Value::new_iutf8("uuid")),
("acp_search_attr", Value::new_iutf8("domain_display_name")),
("acp_search_attr", Value::new_iutf8("domain_name")),
("acp_search_attr", Value::new_iutf8("domain_ldap_basedn")),
("acp_search_attr", Value::new_iutf8("domain_ssid")),
("acp_search_attr", Value::new_iutf8("domain_uuid")),
("acp_search_attr", Value::new_iutf8("es256_private_key_der")),
@ -1226,10 +1185,12 @@ lazy_static! {
("acp_search_attr", Value::new_iutf8("cookie_private_key")),
("acp_modify_removedattr", Value::new_iutf8("domain_display_name")),
("acp_modify_removedattr", Value::new_iutf8("domain_ssid")),
("acp_modify_removedattr", Value::new_iutf8("domain_ldap_basedn")),
("acp_modify_removedattr", Value::new_iutf8("es256_private_key_der")),
("acp_modify_removedattr", Value::new_iutf8("cookie_private_key")),
("acp_modify_removedattr", Value::new_iutf8("fernet_private_key_str")),
("acp_modify_presentattr", Value::new_iutf8("domain_display_name")),
("acp_modify_presentattr", Value::new_iutf8("domain_ldap_basedn")),
("acp_modify_presentattr", Value::new_iutf8("domain_ssid"))
);
}

View file

@ -235,6 +235,34 @@ pub const JSON_SCHEMA_ATTR_DOMAIN_NAME: &str = r#"{
}
}"#;
pub const JSON_SCHEMA_ATTR_DOMAIN_LDAP_BASEDN: &str = r#"{
"attrs": {
"class": [
"object",
"system",
"attributetype"
],
"description": [
"The domain's optional ldap basedn. If unset defaults to domain components of domain name."
],
"unique": [
"true"
],
"multivalue": [
"false"
],
"attributename": [
"domain_ldap_basedn"
],
"syntax": [
"UTF8STRING_INSENSITIVE"
],
"uuid": [
"00000000-0000-0000-0000-ffff00000131"
]
}
}"#;
pub const JSON_SCHEMA_ATTR_DOMAIN_DISPLAY_NAME: &str = r#"{
"attrs": {
"class": [
@ -1650,7 +1678,8 @@ pub const JSON_SCHEMA_CLASS_DOMAIN_INFO: &str = r#"
"domain_info"
],
"systemmay": [
"domain_ssid"
"domain_ssid",
"domain_ldap_basedn"
],
"systemmust": [
"name",

View file

@ -224,6 +224,8 @@ pub const UUID_SCHEMA_ATTR_EMAILALTERNATIVE: Uuid = uuid!("00000000-0000-0000-00
pub const UUID_SCHEMA_ATTR_TOTP_IMPORT: Uuid = uuid!("00000000-0000-0000-0000-ffff00000128");
pub const UUID_SCHEMA_ATTR_REPLICATED: Uuid = uuid!("00000000-0000-0000-0000-ffff00000129");
pub const UUID_SCHEMA_ATTR_PRIVATE_COOKIE_KEY: Uuid = uuid!("00000000-0000-0000-0000-ffff00000130");
pub const _UUID_SCHEMA_ATTR_DOMAIN_LDAP_BASEDN: Uuid =
uuid!("00000000-0000-0000-0000-ffff00000131");
// System and domain infos
// I'd like to strongly criticise william of the past for making poor choices about these allocations.

View file

@ -67,13 +67,16 @@ impl LdapServer {
.qs_read
.internal_search_uuid(UUID_DOMAIN_INFO)?;
let domain_name = domain_entry
.get_ava_single_iname("domain_name")
let basedn = domain_entry
.get_ava_single_iutf8("domain_ldap_basedn")
.map(|s| s.to_string())
.or_else(|| {
domain_entry
.get_ava_single_iname("domain_name")
.map(|domain_name| ldap_domain_to_dc(domain_name))
})
.ok_or(OperationError::InvalidEntryState)?;
let basedn = ldap_domain_to_dc(domain_name.as_str());
let dnre = Regex::new(format!("^((?P<attr>[^=]+)=(?P<val>[^=]+),)?{basedn}$").as_str())
.map_err(|_| OperationError::InvalidEntryState)?;
@ -84,27 +87,27 @@ impl LdapServer {
dn: "".to_string(),
attributes: vec![
LdapPartialAttribute {
atype: "objectClass".to_string(),
atype: "objectclass".to_string(),
vals: vec!["top".as_bytes().to_vec()],
},
LdapPartialAttribute {
atype: "vendorName".to_string(),
atype: "vendorname".to_string(),
vals: vec!["Kanidm Project".as_bytes().to_vec()],
},
LdapPartialAttribute {
atype: "vendorVersion".to_string(),
vals: vec!["kanidm_ldap_1.0.0".as_bytes().to_vec()],
atype: "vendorversion".to_string(),
vals: vec![env!("CARGO_PKG_VERSION").as_bytes().to_vec()],
},
LdapPartialAttribute {
atype: "supportedLDAPVersion".to_string(),
atype: "supportedldapversion".to_string(),
vals: vec!["3".as_bytes().to_vec()],
},
LdapPartialAttribute {
atype: "supportedExtension".to_string(),
atype: "supportedextension".to_string(),
vals: vec!["1.3.6.1.4.1.4203.1.11.3".as_bytes().to_vec()],
},
LdapPartialAttribute {
atype: "supportedFeatures".to_string(),
atype: "supportedfeatures".to_string(),
vals: vec!["1.3.6.1.4.1.4203.1.5.1".as_bytes().to_vec()],
},
LdapPartialAttribute {
@ -1161,4 +1164,91 @@ mod tests {
_ => assert!(false),
};
}
#[idm_test]
async fn test_ldap_rootdse_basedn_change(idms: &IdmServer, _idms_delayed: &IdmServerDelayed) {
let ldaps = LdapServer::new(idms).await.expect("failed to start ldap");
let anon_t = ldaps.do_bind(idms, "", "").await.unwrap().unwrap();
assert!(anon_t.effective_session == LdapSession::UnixBind(UUID_ANONYMOUS));
let sr = SearchRequest {
msgid: 1,
base: "".to_string(),
scope: LdapSearchScope::Base,
filter: LdapFilter::Present("objectclass".to_string()),
attrs: vec!["*".to_string()],
};
let r1 = ldaps.do_search(idms, &sr, &anon_t).await.unwrap();
trace!(?r1);
// The result, and the ldap proto success msg.
assert!(r1.len() == 2);
match &r1[0].op {
LdapOp::SearchResultEntry(lsre) => {
assert_entry_contains!(
lsre,
"",
("objectclass", "top"),
("vendorname", "Kanidm Project"),
("supportedldapversion", "3"),
("defaultnamingcontext", "dc=example,dc=com")
);
}
_ => assert!(false),
};
drop(ldaps);
// Change the domain basedn
let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await;
// make the admin a valid posix account
let me_posix = unsafe {
ModifyEvent::new_internal_invalid(
filter!(f_eq("uuid", PartialValue::Uuid(UUID_DOMAIN_INFO))),
ModifyList::new_purge_and_set(
"domain_ldap_basedn",
Value::new_iutf8("o=kanidmproject"),
),
)
};
assert!(idms_prox_write.qs_write.modify(&me_posix).is_ok());
assert!(idms_prox_write.commit().is_ok());
// Now re-test
let ldaps = LdapServer::new(idms).await.expect("failed to start ldap");
let anon_t = ldaps.do_bind(idms, "", "").await.unwrap().unwrap();
assert!(anon_t.effective_session == LdapSession::UnixBind(UUID_ANONYMOUS));
let sr = SearchRequest {
msgid: 1,
base: "".to_string(),
scope: LdapSearchScope::Base,
filter: LdapFilter::Present("objectclass".to_string()),
attrs: vec!["*".to_string()],
};
let r1 = ldaps.do_search(idms, &sr, &anon_t).await.unwrap();
trace!(?r1);
// The result, and the ldap proto success msg.
assert!(r1.len() == 2);
match &r1[0].op {
LdapOp::SearchResultEntry(lsre) => {
assert_entry_contains!(
lsre,
"",
("objectclass", "top"),
("vendorname", "Kanidm Project"),
("supportedldapversion", "3"),
("defaultnamingcontext", "o=kanidmproject")
);
}
_ => assert!(false),
};
}
}

View file

@ -9,12 +9,21 @@ use std::iter::once;
use compact_jwt::JwsSigner;
use kanidm_proto::v1::OperationError;
use rand::prelude::*;
use regex::Regex;
use tracing::trace;
use crate::event::{CreateEvent, ModifyEvent};
use crate::plugins::Plugin;
use crate::prelude::*;
lazy_static! {
pub static ref DOMAIN_LDAP_BASEDN_RE: Regex = {
#[allow(clippy::expect_used)]
Regex::new(r"^(dc|o|ou)=[a-z][a-z0-9]*(,(dc|o|ou)=[a-z][a-z0-9]*)*$")
.expect("Invalid domain ldap basedn regex")
};
}
pub struct Domain {}
impl Plugin for Domain {
@ -59,6 +68,16 @@ impl Domain {
if e.attribute_equality("class", &PVCLASS_DOMAIN_INFO)
&& e.attribute_equality("uuid", &PVUUID_DOMAIN_INFO)
{
// Validate the domain ldap basedn syntax.
if let Some(basedn) = e
.get_ava_single_iutf8("domain_ldap_basedn") {
if !DOMAIN_LDAP_BASEDN_RE.is_match(basedn) {
error!("Invalid domain_ldap_basedn. Must pass regex \"{}\"", *DOMAIN_LDAP_BASEDN_RE);
return Err(OperationError::InvalidState);
}
}
// We always set this, because the DB uuid is authoritative.
let u = Value::Uuid(qs.get_domain_uuid());
e.set_ava("domain_uuid", once(u));

View file

@ -16,7 +16,7 @@ pub struct Protected {}
lazy_static! {
static ref ALLOWED_ATTRS: HashSet<&'static str> = {
let mut m = HashSet::with_capacity(8);
let mut m = HashSet::with_capacity(16);
// Allow modification of some schema class types to allow local extension
// of schema types.
//
@ -24,6 +24,7 @@ lazy_static! {
m.insert("may");
// Allow modification of some domain info types for local configuration.
m.insert("domain_ssid");
m.insert("domain_ldap_basedn");
m.insert("fernet_private_key_str");
m.insert("es256_private_key_der");
m.insert("badlist_password");

View file

@ -465,6 +465,7 @@ impl<'a> QueryServerWriteTransaction<'a> {
JSON_SCHEMA_ATTR_SYNC_COOKIE,
JSON_SCHEMA_ATTR_GRANT_UI_HINT,
JSON_SCHEMA_ATTR_OAUTH2_RS_ORIGIN_LANDING,
JSON_SCHEMA_ATTR_DOMAIN_LDAP_BASEDN,
JSON_SCHEMA_CLASS_PERSON,
JSON_SCHEMA_CLASS_ORGPERSON,
JSON_SCHEMA_CLASS_GROUP,

View file

@ -41,7 +41,7 @@ lazy_static! {
Regex::new("(?P<name>[^@]+)@(?P<realm>[^@]+)").expect("Invalid SPN regex found")
};
pub static ref DISALLOWED_NAMES: HashSet<&'static str> = {
let mut m = HashSet::with_capacity(10);
let mut m = HashSet::with_capacity(16);
m.insert("root");
m.insert("nobody");
m.insert("nogroup");
@ -52,6 +52,7 @@ lazy_static! {
m.insert("mail");
m.insert("man");
m.insert("administrator");
m.insert("dn=token");
m
};

View file

@ -4,14 +4,15 @@ use crate::DomainOpt;
impl DomainOpt {
pub fn debug(&self) -> bool {
match self {
DomainOpt::SetDomainDisplayName(copt) => copt.copt.debug,
DomainOpt::SetDisplayName(copt) => copt.copt.debug,
DomainOpt::SetLdapBasedn { copt, .. } => copt.debug,
DomainOpt::Show(copt) | DomainOpt::ResetTokenKey(copt) => copt.debug,
}
}
pub async fn exec(&self) {
match self {
DomainOpt::SetDomainDisplayName(opt) => {
DomainOpt::SetDisplayName(opt) => {
eprintln!(
"Attempting to set the domain's display name to: {:?}",
opt.new_display_name
@ -25,6 +26,17 @@ impl DomainOpt {
Err(e) => eprintln!("{:?}", e),
}
}
DomainOpt::SetLdapBasedn { copt, new_basedn } => {
eprintln!(
"Attempting to set the domain's ldap basedn to: {:?}",
new_basedn
);
let client = copt.to_client(OpType::Write).await;
match client.idm_domain_set_ldap_basedn(&new_basedn).await {
Ok(_) => println!("Success"),
Err(e) => eprintln!("{:?}", e),
}
}
DomainOpt::Show(copt) => {
let client = copt.to_client(OpType::Read).await;
match client.idm_domain_get().await {

View file

@ -743,9 +743,20 @@ pub enum PwBadlistOpt {
#[derive(Debug, Subcommand)]
pub enum DomainOpt {
#[clap[name = "set-domain-display-name"]]
#[clap[name = "set-display-name"]]
/// Set the domain display name
SetDomainDisplayName(OptSetDomainDisplayName),
SetDisplayName(OptSetDomainDisplayName),
#[clap[name = "set-ldap-basedn"]]
/// Change the basedn of this server. Takes effect after a server restart.
/// Examples are `o=organisation` or `dc=domain,dc=name`. Must be a valid ldap
/// dn containing only alphanumerics, and dn components must be org (o), domain (dc) or
/// orgunit (ou).
SetLdapBasedn {
#[clap(flatten)]
copt: CommonOpt,
#[clap(name = "new-basedn")]
new_basedn: String,
},
#[clap(name = "show")]
/// Show information about this system's domain
Show(CommonOpt),