Skip to content

Commit 38e508b

Browse files
authored
Allow AD samAccountName generation to be customized (#454)
* Add option to customize AD samAccountName generation * CRD docs * Update CRD * Changelog * Mark samAccountName customization as experimental * Document samAccountName customization * Clean up dead code * Cleanup * Remove unused callouts * Cache randomness dictionaries * Expand name generation docs * Only apply experimental prefix in serde
1 parent 4c324c7 commit 38e508b

File tree

10 files changed

+183
-15
lines changed

10 files changed

+183
-15
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Active Directory's `samAccountName` generation can now be customized ([#454]).
10+
11+
[#454]: https://github.com/stackabletech/secret-operator/pull/454
12+
713
## [24.7.0] - 2024-07-24
814

915
### Added

deploy/helm/secret-operator/crds/crds.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,24 @@ spec:
114114
activeDirectory:
115115
description: Credentials should be provisioned in a Microsoft Active Directory domain.
116116
properties:
117+
experimentalGenerateSamAccountName:
118+
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.
119+
nullable: true
120+
properties:
121+
prefix:
122+
default: ''
123+
description: A prefix to be prepended to generated samAccountNames.
124+
type: string
125+
totalLength:
126+
default: 20
127+
description: |-
128+
The total length of generated samAccountNames, _including_ `prefix`. Must be larger than the length of `prefix`, but at most `20`.
129+
130+
Note that this should be as large as possible, to minimize the risk of collisions.
131+
format: uint8
132+
minimum: 0.0
133+
type: integer
134+
type: object
117135
ldapServer:
118136
description: An AD LDAP server, such as the AD Domain Controller. This must match the server’s FQDN, or GSSAPI authentication will fail.
119137
type: string

docs/modules/secret-operator/pages/secretclass.adoc

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,37 @@ If the same AD domain _is_ shared between multiple Kubernetes clusters, the foll
122122
- The Kubernetes Nodes' names and fully qualified domain names
123123
- The Kubernetes Namespaces' names (only Namespaces that use Kerberos)
124124

125+
[#ad-samaccountname]
126+
===== Custom `samAccountName` generation
127+
128+
IMPORTANT: `samAccountName` customization is an experimental preview feature, and may be changed or removed at any time.
129+
130+
By default, the `samAccountName` for created Active Directory accounts will be generated by Active Directory.
131+
132+
These can be customized if required, such as for compliance with internal policies. When customization is enabled,
133+
the name will follow the pattern `\{prefix}\{random}`, where `\{random}` is a sequence of random alphanumeric characters.
134+
The full name will be exactly `totalLength` characters.
135+
136+
For example, the following configuration will generate samAccountNames such as "myprefix-abcd".
137+
138+
[source,yaml]
139+
----
140+
spec:
141+
backend:
142+
kerberosKeytab:
143+
admin:
144+
activeDirectory:
145+
experimentalGenerateSamAccountName:
146+
prefix: myprefix-
147+
totalLength: 13
148+
----
149+
150+
`kerberosKeytab.admin.activeDirectory.experimentalGenerateSamAccountName.prefix`:: A prefix that will be prepended to all generated `samAccountName` values.
151+
`kerberosKeytab.admin.activeDirectory.experimentalGenerateSamAccountName.totalLength`:: The desired length of `samAccountName` values, _including_ `prefix`. Must not be larger than 20.
152+
153+
NOTE: These options only affect _newly created_ accounts. Existing accounts will keep their respective old `samAccountName`.
154+
155+
[#ad-reference]
125156
==== Reference
126157

127158
[source,yaml]

rust/krb5-provision-keytab/src/active_directory.rs

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
use std::ffi::{CString, NulError};
1+
use std::{
2+
collections::HashSet,
3+
ffi::{CString, NulError},
4+
sync::OnceLock,
5+
};
26

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

@@ -61,6 +66,9 @@ pub enum Error {
6166

6267
#[snafu(display("failed to add key to keytab"))]
6368
AddToKeytab { source: krb5::Error },
69+
70+
#[snafu(display("configured samAccountName prefix is longer than the requested length"))]
71+
SamAccountNamePrefixLongerThanRequestedLength,
6472
}
6573
pub type Result<T, E = Error> = std::result::Result<T, E>;
6674

@@ -79,6 +87,7 @@ pub struct AdAdmin<'a> {
7987
password_cache: CredentialCache,
8088
user_distinguished_name: String,
8189
schema_distinguished_name: String,
90+
generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
8291
}
8392

8493
impl<'a> AdAdmin<'a> {
@@ -89,6 +98,7 @@ impl<'a> AdAdmin<'a> {
8998
password_cache_secret: SecretReference,
9099
user_distinguished_name: String,
91100
schema_distinguished_name: String,
101+
generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
92102
) -> Result<AdAdmin<'a>> {
93103
let kube = stackable_operator::client::create_client(None)
94104
.await
@@ -117,6 +127,7 @@ impl<'a> AdAdmin<'a> {
117127
password_cache,
118128
user_distinguished_name,
119129
schema_distinguished_name,
130+
generate_sam_account_name,
120131
})
121132
}
122133

@@ -143,6 +154,7 @@ impl<'a> AdAdmin<'a> {
143154
&self.user_distinguished_name,
144155
&self.schema_distinguished_name,
145156
ctx.cache_ref,
157+
self.generate_sam_account_name.as_ref(),
146158
)
147159
.await?;
148160
Ok(password.into_bytes())
@@ -186,21 +198,40 @@ async fn get_ldap_ca_certificate(
186198
native_tls::Certificate::from_pem(&ca_cert_pem).context(ParseLdapTlsCaSnafu)
187199
}
188200

189-
fn generate_ad_password(len: usize) -> String {
201+
fn generate_random_string(len: usize, dict: &[char]) -> String {
190202
let mut rng = thread_rng();
191203
// Assert that `rng` is crypto-safe
192204
let _: &dyn CryptoRng = &rng;
193-
// Allow all ASCII alphanumeric characters as well as punctuation
194-
// Exclude double quotes (") since they are used by the AD password update protocol...
195-
let dict: Vec<char> = (1..=127)
196-
.filter_map(char::from_u32)
197-
.filter(|c| *c != '"' && (c.is_ascii_alphanumeric() || c.is_ascii_punctuation()))
198-
.collect();
199-
let pw = (0..len)
205+
let str = (0..len)
200206
.map(|_| *dict.choose(&mut rng).expect("dictionary must be non-empty"))
201207
.collect::<String>();
202-
assert_eq!(pw.len(), len);
203-
pw
208+
assert_eq!(str.len(), len);
209+
str
210+
}
211+
212+
fn generate_ad_password(len: usize) -> String {
213+
// Allow all ASCII alphanumeric characters as well as punctuation
214+
// Exclude double quotes (") since they are used by the AD password update protocol...
215+
static DICT: OnceLock<Vec<char>> = OnceLock::new();
216+
let dict = DICT.get_or_init(|| {
217+
(1..=127)
218+
.filter_map(char::from_u32)
219+
.filter(|c| *c != '"' && (c.is_ascii_alphanumeric() || c.is_ascii_punctuation()))
220+
.collect()
221+
});
222+
generate_random_string(len, dict)
223+
}
224+
225+
fn generate_username(len: usize) -> String {
226+
// Allow ASCII alphanumerics
227+
static DICT: OnceLock<Vec<char>> = OnceLock::new();
228+
let dict = DICT.get_or_init(|| {
229+
(1..=127)
230+
.filter_map(char::from_u32)
231+
.filter(|c| c.is_ascii_alphanumeric())
232+
.collect()
233+
});
234+
generate_random_string(len, dict)
204235
}
205236

206237
fn encode_password_for_ad_update(password: &str) -> Vec<u8> {
@@ -220,6 +251,7 @@ async fn create_ad_user(
220251
user_dn_base: &str,
221252
schema_dn_base: &str,
222253
password_cache_ref: SecretReference,
254+
generate_sam_account_name: Option<&ActiveDirectorySamAccountNameRules>,
223255
) -> Result<()> {
224256
// Flags are a subset of https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
225257
const AD_UAC_NORMAL_ACCOUNT: u32 = 0x0200;
@@ -242,10 +274,20 @@ async fn create_ad_user(
242274
let principal_cn = ldap3::dn_escape(&princ_name);
243275
// FIXME: AD restricts RDNs to 64 characters
244276
let principal_cn = principal_cn.get(..64).unwrap_or(&*principal_cn);
277+
let sam_account_name = generate_sam_account_name
278+
.map(|sam_rules| {
279+
let mut name = sam_rules.prefix.clone();
280+
let random_part_len = usize::from(sam_rules.total_length)
281+
.checked_sub(name.len())
282+
.context(SamAccountNamePrefixLongerThanRequestedLengthSnafu)?;
283+
name += &generate_username(random_part_len);
284+
Ok(name)
285+
})
286+
.transpose()?;
245287
let create_user_result = ldap
246288
.add(
247289
&format!("CN={principal_cn},{user_dn_base}"),
248-
vec![
290+
[
249291
("cn".as_bytes(), [principal_cn.as_bytes()].into()),
250292
("objectClass".as_bytes(), ["user".as_bytes()].into()),
251293
("instanceType".as_bytes(), ["4".as_bytes()].into()),
@@ -280,7 +322,14 @@ async fn create_ad_user(
280322
.as_bytes()]
281323
.into(),
282324
),
283-
],
325+
]
326+
.into_iter()
327+
.chain(
328+
sam_account_name
329+
.as_ref()
330+
.map(|san| ("samAccountName".as_bytes(), HashSet::from([san.as_bytes()]))),
331+
)
332+
.collect(),
284333
)
285334
.await
286335
.context(CreateLdapUserSnafu)?;

rust/krb5-provision-keytab/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ pub enum AdminBackend {
3131
password_cache_secret: SecretReference,
3232
user_distinguished_name: String,
3333
schema_distinguished_name: String,
34+
generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
3435
},
3536
}
37+
#[derive(Serialize, Deserialize, Debug)]
38+
pub struct ActiveDirectorySamAccountNameRules {
39+
pub prefix: String,
40+
pub total_length: u8,
41+
}
3642

3743
#[derive(Serialize, Deserialize)]
3844
pub struct Response {}

rust/krb5-provision-keytab/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ async fn run() -> Result<Response, Error> {
9494
password_cache_secret,
9595
user_distinguished_name,
9696
schema_distinguished_name,
97+
generate_sam_account_name,
9798
} => AdminConnection::ActiveDirectory(
9899
active_directory::AdAdmin::connect(
99100
&ldap_server,
@@ -102,6 +103,7 @@ async fn run() -> Result<Response, Error> {
102103
password_cache_secret,
103104
user_distinguished_name,
104105
schema_distinguished_name,
106+
generate_sam_account_name,
105107
)
106108
.await
107109
.context(ActiveDirectoryInitSnafu)?,

rust/operator-binary/src/backend/kerberos_keytab.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
use async_trait::async_trait;
22
use snafu::{OptionExt, ResultExt, Snafu};
3-
use stackable_krb5_provision_keytab::provision_keytab;
3+
use stackable_krb5_provision_keytab::{
4+
// Some qualified paths get long enough to break rustfmt, alias the crate name to work around that
5+
self as provision,
6+
provision_keytab,
7+
};
48
use stackable_operator::{k8s_openapi::api::core::v1::Secret, kube::runtime::reflector::ObjectRef};
59
use stackable_secret_operator_crd_utils::SecretReference;
610
use tempfile::tempdir;
@@ -10,7 +14,10 @@ use tokio::{
1014
};
1115

1216
use crate::{
13-
crd::{Hostname, InvalidKerberosPrincipal, KerberosKeytabBackendAdmin, KerberosPrincipal},
17+
crd::{
18+
ActiveDirectorySamAccountNameRules, Hostname, InvalidKerberosPrincipal,
19+
KerberosKeytabBackendAdmin, KerberosPrincipal,
20+
},
1421
format::{well_known, SecretData, WellKnownSecretData},
1522
utils::Unloggable,
1623
};
@@ -229,12 +236,24 @@ cluster.local = {realm_name}
229236
password_cache_secret,
230237
user_distinguished_name,
231238
schema_distinguished_name,
239+
generate_sam_account_name,
232240
} => stackable_krb5_provision_keytab::AdminBackend::ActiveDirectory {
233241
ldap_server: ldap_server.to_string(),
234242
ldap_tls_ca_secret: ldap_tls_ca_secret.clone(),
235243
password_cache_secret: password_cache_secret.clone(),
236244
user_distinguished_name: user_distinguished_name.clone(),
237245
schema_distinguished_name: schema_distinguished_name.clone(),
246+
generate_sam_account_name: generate_sam_account_name.clone().map(
247+
|ActiveDirectorySamAccountNameRules {
248+
prefix,
249+
total_length,
250+
}| {
251+
provision::ActiveDirectorySamAccountNameRules {
252+
prefix,
253+
total_length,
254+
}
255+
},
256+
),
238257
},
239258
},
240259
},

rust/operator-binary/src/crd.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,36 @@ pub enum KerberosKeytabBackendAdmin {
175175
/// The root Distinguished Name (DN) for AD-managed schemas,
176176
/// typically `CN=Schema,CN=Configuration,{domain_dn}`.
177177
schema_distinguished_name: String,
178+
179+
/// Allows samAccountName generation for new accounts to be customized.
180+
/// Note that setting this field (even if empty) makes the Secret Operator take
181+
/// over the generation duty from the domain controller.
182+
#[serde(rename = "experimentalGenerateSamAccountName")]
183+
generate_sam_account_name: Option<ActiveDirectorySamAccountNameRules>,
178184
},
179185
}
180186

187+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
188+
#[serde(rename_all = "camelCase")]
189+
pub struct ActiveDirectorySamAccountNameRules {
190+
/// A prefix to be prepended to generated samAccountNames.
191+
#[serde(default)]
192+
pub prefix: String,
193+
/// The total length of generated samAccountNames, _including_ `prefix`.
194+
/// Must be larger than the length of `prefix`, but at most `20`.
195+
///
196+
/// Note that this should be as large as possible, to minimize the risk of collisions.
197+
#[serde(default = "ActiveDirectorySamAccountNameRules::default_total_length")]
198+
pub total_length: u8,
199+
}
200+
201+
impl ActiveDirectorySamAccountNameRules {
202+
fn default_total_length() -> u8 {
203+
// Default AD samAccountName length limit
204+
20
205+
}
206+
}
207+
181208
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
182209
#[serde(try_from = "String", into = "String")]
183210
pub struct Hostname(String);

tests/templates/kuttl/kerberos-ad/secretclass.yaml renamed to tests/templates/kuttl/kerberos-ad/secretclass.yaml.j2

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ spec:
2323
# Subfolder must be created manually, of type "msDS-ShadowPrincipalContainer"
2424
userDistinguishedName: CN=Stackable,CN=Users,DC=sble,DC=test
2525
schemaDistinguishedName: CN=Schema,CN=Configuration,DC=sble,DC=test
26+
{% if test_scenario['values']['ad-custom-samaccountname'] == 'true' %}
27+
experimentalGenerateSamAccountName:
28+
prefix: sble-
29+
totalLength: 15
30+
{% endif %}
2631
adminKeytabSecret:
2732
# Created by AD administrator
2833
name: secret-operator-keytab

tests/test-definition.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ dimensions:
66
- name: openshift
77
values:
88
- "false"
9+
- name: ad-custom-samaccountname
10+
values:
11+
- "false"
12+
- "true"
913
tests:
1014
- name: kerberos
1115
dimensions:
@@ -15,6 +19,7 @@ tests:
1519
# - name: kerberos-ad
1620
# dimensions:
1721
# - krb5
22+
# - ad-custom-samaccountname
1823
- name: listener
1924
dimensions:
2025
- openshift

0 commit comments

Comments
 (0)