Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow AD samAccountName generation to be customized #454

Merged
merged 14 commits into from
Jul 29, 2024
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added

- The associated configuration is now logged for each issued secret ([#413]).
- Active Directory's `samAccountName` generation can now be customized ([#454]).
sbernauer marked this conversation as resolved.
Show resolved Hide resolved

### Changed

Expand All @@ -30,6 +31,7 @@ All notable changes to this project will be documented in this file.
[#403]: https://github.com/stackabletech/secret-operator/pull/403
[#413]: https://github.com/stackabletech/secret-operator/pull/413
[#440]: https://github.com/stackabletech/secret-operator/pull/440
[#454]: https://github.com/stackabletech/secret-operator/pull/454
[#467]: https://github.com/stackabletech/secret-operator/pull/467
[#468]: https://github.com/stackabletech/secret-operator/pull/468
[#470]: https://github.com/stackabletech/secret-operator/pull/470
Expand Down
18 changes: 18 additions & 0 deletions deploy/helm/secret-operator/crds/crds.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ spec:
activeDirectory:
description: Credentials should be provisioned in a Microsoft Active Directory domain.
properties:
experimentalGenerateSamAccountName:
description: Allows samAccountName generation for new accounts to be customized. Note that setting this field (even if empty) makes the Secret Operator take over the generation duty from the domain controller.
nullable: true
properties:
prefix:
default: ''
description: A prefix to be prepended to generated samAccountNames.
type: string
totalLength:
default: 20
description: |-
The total length of generated samAccountNames, _including_ `prefix`. Must be larger than the length of `prefix`, but at most `20`.

Note that this should be as large as possible, to minimize the risk of collisions.
format: uint8
minimum: 0.0
type: integer
type: object
ldapServer:
description: An AD LDAP server, such as the AD Domain Controller. This must match the server’s FQDN, or GSSAPI authentication will fail.
type: string
Expand Down
28 changes: 28 additions & 0 deletions docs/modules/secret-operator/pages/secretclass.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,34 @@ If the same AD domain _is_ shared between multiple Kubernetes clusters, the foll
- The Kubernetes Nodes' names and fully qualified domain names
- The Kubernetes Namespaces' names (only Namespaces that use Kerberos)

[#ad-samaccountname]
===== Custom `samAccountName` generation

IMPORTANT: `samAccountName` customization is an experimental preview feature, and may be changed or removed at any time.

By default, the `samAccountName` for created Active Directory accounts will be generated by Active Directory.

These can be customized if required, such as for compliance with internal policies. For example, the following configuration will generate
samAccountNames following the pattern "myprefix-abcd".

[source,yaml]
----
spec:
backend:
kerberosKeytab:
admin:
activeDirectory:
experimentalGenerateSamAccountName:
prefix: myprefix- # <1>
totalLength: 13 # <2>
sbernauer marked this conversation as resolved.
Show resolved Hide resolved
----

`kerberosKeytab.admin.activeDirectory.experimentalGenerateSamAccountName.prefix`:: A prefix that will be prepended to all generated `samAccountName` values.
`kerberosKeytab.admin.activeDirectory.experimentalGenerateSamAccountName.totalLength`:: The desired length of `samAccountName` values, _including_ `prefix`. Must not be larger than 20.
sbernauer marked this conversation as resolved.
Show resolved Hide resolved

NOTE: These options only affect _newly created_ accounts. Existing accounts will keep their respective old `samAccountName`.

[#ad-reference]
==== Reference

[source,yaml]
Expand Down
60 changes: 51 additions & 9 deletions rust/krb5-provision-keytab/src/active_directory.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
use std::ffi::{CString, NulError};
use std::{
collections::HashSet,
ffi::{CString, NulError},
};

use byteorder::{LittleEndian, WriteBytesExt};
use krb5::{Keyblock, Keytab, KrbContext, Principal, PrincipalUnparseOptions};
use ldap3::{Ldap, LdapConnAsync, LdapConnSettings};
use rand::{seq::SliceRandom, thread_rng, CryptoRng};
use snafu::{OptionExt, ResultExt, Snafu};
use stackable_krb5_provision_keytab::ActiveDirectorySamAccountNameRules;
use stackable_operator::{k8s_openapi::api::core::v1::Secret, kube::runtime::reflector::ObjectRef};
use stackable_secret_operator_crd_utils::SecretReference;

Expand Down Expand Up @@ -61,6 +65,9 @@ pub enum Error {

#[snafu(display("failed to add key to keytab"))]
AddToKeytab { source: krb5::Error },

#[snafu(display("configured samAccountName prefix is longer than the requested length"))]
SamAccountNamePrefixLongerThanRequestedLength,
}
pub type Result<T, E = Error> = std::result::Result<T, E>;

Expand All @@ -79,6 +86,7 @@ pub struct AdAdmin<'a> {
password_cache: CredentialCache,
user_distinguished_name: String,
schema_distinguished_name: String,
generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
}

impl<'a> AdAdmin<'a> {
Expand All @@ -89,6 +97,7 @@ impl<'a> AdAdmin<'a> {
password_cache_secret: SecretReference,
user_distinguished_name: String,
schema_distinguished_name: String,
generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
) -> Result<AdAdmin<'a>> {
let kube = stackable_operator::client::create_client(None)
.await
Expand Down Expand Up @@ -117,6 +126,7 @@ impl<'a> AdAdmin<'a> {
password_cache,
user_distinguished_name,
schema_distinguished_name,
generate_sam_account_name,
})
}

Expand All @@ -143,6 +153,7 @@ impl<'a> AdAdmin<'a> {
&self.user_distinguished_name,
&self.schema_distinguished_name,
ctx.cache_ref,
self.generate_sam_account_name.as_ref(),
)
.await?;
Ok(password.into_bytes())
Expand Down Expand Up @@ -186,21 +197,34 @@ async fn get_ldap_ca_certificate(
native_tls::Certificate::from_pem(&ca_cert_pem).context(ParseLdapTlsCaSnafu)
}

fn generate_ad_password(len: usize) -> String {
fn generate_random_string(len: usize, dict: &[char]) -> String {
let mut rng = thread_rng();
// Assert that `rng` is crypto-safe
let _: &dyn CryptoRng = &rng;
let str = (0..len)
.map(|_| *dict.choose(&mut rng).expect("dictionary must be non-empty"))
.collect::<String>();
assert_eq!(str.len(), len);
str
sbernauer marked this conversation as resolved.
Show resolved Hide resolved
}

fn generate_ad_password(len: usize) -> String {
// Allow all ASCII alphanumeric characters as well as punctuation
// Exclude double quotes (") since they are used by the AD password update protocol...
let dict: Vec<char> = (1..=127)
.filter_map(char::from_u32)
.filter(|c| *c != '"' && (c.is_ascii_alphanumeric() || c.is_ascii_punctuation()))
.collect();
let pw = (0..len)
.map(|_| *dict.choose(&mut rng).expect("dictionary must be non-empty"))
.collect::<String>();
assert_eq!(pw.len(), len);
pw
generate_random_string(len, &dict)
}

fn generate_username(len: usize) -> String {
// Allow ASCII alphanumerics
let dict: Vec<char> = (1..=127)
.filter_map(char::from_u32)
sbernauer marked this conversation as resolved.
Show resolved Hide resolved
.filter(|c| c.is_ascii_alphanumeric())
.collect();
generate_random_string(len, &dict)
}

fn encode_password_for_ad_update(password: &str) -> Vec<u8> {
Expand All @@ -220,6 +244,7 @@ async fn create_ad_user(
user_dn_base: &str,
schema_dn_base: &str,
password_cache_ref: SecretReference,
generate_sam_account_name: Option<&ActiveDirectorySamAccountNameRules>,
) -> Result<()> {
// Flags are a subset of https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
const AD_UAC_NORMAL_ACCOUNT: u32 = 0x0200;
Expand All @@ -242,10 +267,20 @@ async fn create_ad_user(
let principal_cn = ldap3::dn_escape(&princ_name);
// FIXME: AD restricts RDNs to 64 characters
let principal_cn = principal_cn.get(..64).unwrap_or(&*principal_cn);
let sam_account_name = generate_sam_account_name
.map(|sam_rules| {
let mut name = sam_rules.prefix.clone();
let random_part_len = usize::from(sam_rules.total_length)
.checked_sub(name.len())
.context(SamAccountNamePrefixLongerThanRequestedLengthSnafu)?;
name += &generate_username(random_part_len);
Ok(name)
})
.transpose()?;
let create_user_result = ldap
.add(
&format!("CN={principal_cn},{user_dn_base}"),
vec![
[
("cn".as_bytes(), [principal_cn.as_bytes()].into()),
("objectClass".as_bytes(), ["user".as_bytes()].into()),
("instanceType".as_bytes(), ["4".as_bytes()].into()),
Expand Down Expand Up @@ -280,7 +315,14 @@ async fn create_ad_user(
.as_bytes()]
.into(),
),
],
]
.into_iter()
.chain(
sam_account_name
.as_ref()
.map(|san| ("samAccountName".as_bytes(), HashSet::from([san.as_bytes()]))),
)
.collect(),
)
.await
.context(CreateLdapUserSnafu)?;
Expand Down
6 changes: 6 additions & 0 deletions rust/krb5-provision-keytab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ pub enum AdminBackend {
password_cache_secret: SecretReference,
user_distinguished_name: String,
schema_distinguished_name: String,
generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
},
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ActiveDirectorySamAccountNameRules {
pub prefix: String,
pub total_length: u8,
}

#[derive(Serialize, Deserialize)]
pub struct Response {}
Expand Down
2 changes: 2 additions & 0 deletions rust/krb5-provision-keytab/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ async fn run() -> Result<Response, Error> {
password_cache_secret,
user_distinguished_name,
schema_distinguished_name,
generate_sam_account_name,
} => AdminConnection::ActiveDirectory(
active_directory::AdAdmin::connect(
&ldap_server,
Expand All @@ -102,6 +103,7 @@ async fn run() -> Result<Response, Error> {
password_cache_secret,
user_distinguished_name,
schema_distinguished_name,
generate_sam_account_name,
)
.await
.context(ActiveDirectoryInitSnafu)?,
Expand Down
25 changes: 23 additions & 2 deletions rust/operator-binary/src/backend/kerberos_keytab.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use async_trait::async_trait;
use snafu::{OptionExt, ResultExt, Snafu};
use stackable_krb5_provision_keytab::provision_keytab;
use stackable_krb5_provision_keytab::{
// Some qualified paths get long enough to break rustfmt, alias the crate name to work around that
self as provision,
provision_keytab,
};
use stackable_operator::{k8s_openapi::api::core::v1::Secret, kube::runtime::reflector::ObjectRef};
use stackable_secret_operator_crd_utils::SecretReference;
use tempfile::tempdir;
Expand All @@ -10,7 +14,10 @@ use tokio::{
};

use crate::{
crd::{Hostname, InvalidKerberosPrincipal, KerberosKeytabBackendAdmin, KerberosPrincipal},
crd::{
ActiveDirectorySamAccountNameRules, Hostname, InvalidKerberosPrincipal,
KerberosKeytabBackendAdmin, KerberosPrincipal,
},
format::{well_known, SecretData, WellKnownSecretData},
utils::Unloggable,
};
Expand Down Expand Up @@ -229,12 +236,26 @@ cluster.local = {realm_name}
password_cache_secret,
user_distinguished_name,
schema_distinguished_name,
experimental_generate_sam_account_name,
} => stackable_krb5_provision_keytab::AdminBackend::ActiveDirectory {
ldap_server: ldap_server.to_string(),
ldap_tls_ca_secret: ldap_tls_ca_secret.clone(),
password_cache_secret: password_cache_secret.clone(),
user_distinguished_name: user_distinguished_name.clone(),
schema_distinguished_name: schema_distinguished_name.clone(),
generate_sam_account_name: experimental_generate_sam_account_name
.clone()
.map(
|ActiveDirectorySamAccountNameRules {
prefix,
total_length,
}| {
provision::ActiveDirectorySamAccountNameRules {
prefix,
total_length,
}
},
),
},
},
},
Expand Down
26 changes: 26 additions & 0 deletions rust/operator-binary/src/crd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,35 @@ pub enum KerberosKeytabBackendAdmin {
/// The root Distinguished Name (DN) for AD-managed schemas,
/// typically `CN=Schema,CN=Configuration,{domain_dn}`.
schema_distinguished_name: String,

/// Allows samAccountName generation for new accounts to be customized.
/// Note that setting this field (even if empty) makes the Secret Operator take
/// over the generation duty from the domain controller.
experimental_generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
sbernauer marked this conversation as resolved.
Show resolved Hide resolved
},
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActiveDirectorySamAccountNameRules {
/// A prefix to be prepended to generated samAccountNames.
#[serde(default)]
pub prefix: String,
/// The total length of generated samAccountNames, _including_ `prefix`.
/// Must be larger than the length of `prefix`, but at most `20`.
sbernauer marked this conversation as resolved.
Show resolved Hide resolved
///
/// Note that this should be as large as possible, to minimize the risk of collisions.
#[serde(default = "ActiveDirectorySamAccountNameRules::default_total_length")]
pub total_length: u8,
}

impl ActiveDirectorySamAccountNameRules {
fn default_total_length() -> u8 {
// Default AD samAccountName length limit
20
}
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct Hostname(String);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ spec:
# Subfolder must be created manually, of type "msDS-ShadowPrincipalContainer"
userDistinguishedName: CN=Stackable,CN=Users,DC=sble,DC=test
schemaDistinguishedName: CN=Schema,CN=Configuration,DC=sble,DC=test
{% if test_scenario['values']['ad-custom-samaccountname'] == 'true' %}
experimentalGenerateSamAccountName:
prefix: sble-
totalLength: 15
{% endif %}
adminKeytabSecret:
# Created by AD administrator
name: secret-operator-keytab
Expand Down
5 changes: 5 additions & 0 deletions tests/test-definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ dimensions:
- name: openshift
values:
- "false"
- name: ad-custom-samaccountname
values:
- "false"
- "true"
tests:
- name: kerberos
dimensions:
Expand All @@ -15,6 +19,7 @@ tests:
# - name: kerberos-ad
# dimensions:
# - krb5
# - ad-custom-samaccountname
- name: listener
dimensions:
- openshift
Expand Down
Loading