Skip to content

Commit 4707776

Browse files
oetikerclaude
andcommitted
feat(passwd): accept a username, resolve before prompting
The passwd subcommand used its argument verbatim as the LDAP search base, so a bare username like 'andy' failed with result code 34 (invalidDNSyntax). It only ever accepted a full DN. Now a bare argument (no '=') is resolved to a DN by searching every configured profile's search_base for (<rdn_attr>=<username>). Resolution runs before the password prompt, so an unknown or ambiguous username fails immediately instead of after two password entries; the resolved DN is printed for confirmation. A full DN still works as before, now with an up-front existence check. New src/passwd.rs holds the pure resolution logic (looks_like_dn, username_searches, resolve_outcome) with unit tests; tests/live_passwd.rs covers resolution against the seeded OpenLDAP server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent be75afa commit 4707776

6 files changed

Lines changed: 407 additions & 41 deletions

File tree

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ All notable changes to eDAPtor are documented here. The format follows
88

99
### New
1010

11+
- **`edaptor passwd` accepts a bare username**, not just a full DN. A username
12+
(any argument without an `=`) is resolved to a DN by searching every configured
13+
profile's `search_base` for `(<rdn_attr>=<username>)`. The lookup runs **before**
14+
the password prompt, so an unknown or ambiguous username fails immediately
15+
(with the matching DNs listed on ambiguity) instead of after typing the
16+
password twice. The resolved DN is printed before the prompt for confirmation.
1117
- `examples/oposs-openldap.toml` — ready-to-use config template for directories
1218
managed by the [oposs.openldap](https://github.com/oposs/oposs.openldap)
1319
Ansible role (POSIX users, groupOfNames + posixGroup, Samba and mailAccount

docs/src/usage/passwords.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,39 @@ Samba credentials in sync in the same atomic change:
3232
The result is a single password change that updates both the Unix
3333
(`userPassword`) and Samba (`sambaNTPassword`) credentials together.
3434

35-
## The `edaptor passwd <dn>` CLI
35+
## The `edaptor passwd <user>` CLI
3636

37-
To set a password without entering the TUI, use the `passwd` subcommand:
37+
To set a password without entering the TUI, use the `passwd` subcommand. It
38+
accepts either a **bare username** or a **full DN**:
3839

3940
```bash
40-
edaptor passwd uid=alice,ou=people,dc=example,dc=org
41+
edaptor passwd alice # resolved via the configured profiles
42+
edaptor passwd uid=alice,ou=people,dc=example,dc=org # explicit DN
4143
```
4244

43-
It prompts for the new password twice (no echo), and on a match performs a
44-
single atomic MODIFY that updates `userPassword` and — when the target is a
45+
A bare username (anything without an `=`) is resolved to a DN by searching every
46+
configured profile's `search_base` for `(<rdn_attr>=<username>)` — e.g.
47+
`(uid=alice)` under `ou=people`. The lookup happens **before** the password
48+
prompt, so an unknown or ambiguous username fails immediately instead of after
49+
you type the password:
50+
51+
- **no match**`no entry found for username "alice" …`;
52+
- **more than one match** (e.g. the same name under two profiles) → the matching
53+
DNs are listed and you are asked to pass a full DN instead.
54+
55+
Once the target is resolved, edaptor prints which DN it is about to change, then
56+
prompts for the new password twice (no echo). On a match it performs a single
57+
atomic MODIFY that updates `userPassword` and — when the target is a
4558
`sambaSamAccount``sambaNTPassword` and `sambaPwdLastSet`. This command is
4659
**TLS-only** (it refuses to send a cleartext password over an unencrypted
47-
connection), so the configured server must be reachable over `ldaps://` or with
48-
StartTLS.
60+
connection), so the configured server must be reachable over `ldaps://`, with
61+
StartTLS, or over `ldapi://`.
4962

5063
## Known gap: no standalone in-TUI "Set Password"
5164

5265
There is currently **no standalone "Set Password" action inside the TUI** for an
5366
arbitrary entry. Passwords can be set in the TUI only through the **set-password popup**
5467
on the create/edit form of entries whose profile declares a password widget.
5568
For any other entry — or to (re)set a password outside that form — use the
56-
**`edaptor passwd <dn>`** CLI described above.
69+
**`edaptor passwd <user>`** CLI described above.
5770

src/lib.rs

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod app;
55
pub mod config;
66
pub mod form;
77
pub mod ldap;
8+
pub mod passwd;
89
pub mod samba;
910
pub mod schema;
1011
pub mod testdata;
@@ -149,11 +150,85 @@ fn is_samba_account(object_classes: &[String]) -> bool {
149150
///
150151
/// Factored out of `main` (no `rpassword`, no terminal) so the live test can drive
151152
/// it with a known password.
153+
/// Search `base` (subtree/base scope per `scope`) for `filter`, returning the
154+
/// matching entries with their `objectClass` values. The shared LDAP round-trip
155+
/// behind both passwd-target resolution branches.
156+
fn search_object_classes(
157+
worker: &WorkerHandle,
158+
base: &str,
159+
scope: SearchScope,
160+
filter: &str,
161+
) -> Result<Vec<crate::ldap::worker::LdapEntry>> {
162+
match worker.request(Request::Search {
163+
id: 1,
164+
base: base.to_string(),
165+
scope,
166+
filter: filter.to_string(),
167+
attrs: vec!["objectClass".to_string()],
168+
size_limit: None,
169+
})? {
170+
Response::Entries { entries, .. } => Ok(entries),
171+
Response::SearchError { msg, .. } => Err(anyhow!("searching {base}: {msg}")),
172+
other => Err(anyhow!(
173+
"unexpected response searching {base}: {}",
174+
describe_response(&other)
175+
)),
176+
}
177+
}
178+
179+
/// Resolve the `passwd` argument to a concrete `(dn, objectClass values)`. A DN
180+
/// argument is base-read to confirm it exists; a bare username is searched across
181+
/// every configured profile's `search_base`, requiring a single match.
182+
fn resolve_passwd_target(
183+
worker: &WorkerHandle,
184+
profiles: &[crate::config::EntryProfile],
185+
arg: &str,
186+
) -> Result<(String, Vec<String>)> {
187+
if passwd::looks_like_dn(arg) {
188+
let entry = search_object_classes(worker, arg, SearchScope::Base, "(objectClass=*)")?
189+
.into_iter()
190+
.next()
191+
.ok_or_else(|| anyhow!("entry not found: {arg}"))?;
192+
let ocs = entry.attrs.get("objectClass").cloned().unwrap_or_default();
193+
return Ok((entry.dn, ocs));
194+
}
195+
196+
// Bare username: gather candidates across every profile's search base.
197+
let mut matched: Vec<crate::ldap::worker::LdapEntry> = Vec::new();
198+
for (base, filter) in passwd::username_searches(profiles, arg) {
199+
matched.extend(search_object_classes(
200+
worker,
201+
&base,
202+
SearchScope::Subtree,
203+
&filter,
204+
)?);
205+
}
206+
let dns = matched.iter().map(|e| e.dn.clone()).collect();
207+
match passwd::resolve_outcome(dns) {
208+
passwd::Resolution::Unique(dn) => {
209+
let ocs = matched
210+
.into_iter()
211+
.find(|e| e.dn.eq_ignore_ascii_case(&dn))
212+
.and_then(|e| e.attrs.get("objectClass").cloned())
213+
.unwrap_or_default();
214+
Ok((dn, ocs))
215+
}
216+
passwd::Resolution::NotFound => Err(anyhow!(
217+
"no entry found for username \"{arg}\" in any configured profile; \
218+
pass a full DN instead"
219+
)),
220+
passwd::Resolution::Ambiguous(dns) => Err(anyhow!(
221+
"username \"{arg}\" matches multiple entries:\n {}\npass a full DN instead",
222+
dns.join("\n ")
223+
)),
224+
}
225+
}
226+
152227
pub fn run_passwd(
153228
config: Config,
154229
bind_password: String,
155-
target_dn: &str,
156-
new_password: &str,
230+
target_arg: &str,
231+
prompt: impl FnOnce(&str) -> Result<String>,
157232
) -> Result<String> {
158233
// TLS gate FIRST — before spawning the worker or touching the network.
159234
if !samba::password::is_secure(&config.server) {
@@ -163,42 +238,28 @@ pub fn run_passwd(
163238
));
164239
}
165240

241+
// Profiles are needed for username resolution after `config` moves into the
242+
// worker, so capture them first.
243+
let profiles = config.profiles.clone();
166244
let worker = WorkerHandle::spawn(config, bind_password)?;
167245

168-
// Read the target's objectClass values to detect a sambaSamAccount.
169-
let object_classes = match worker.request(Request::Search {
170-
id: 1,
171-
base: target_dn.to_string(),
172-
scope: SearchScope::Base,
173-
filter: "(objectClass=*)".to_string(),
174-
attrs: vec!["objectClass".to_string()],
175-
size_limit: None,
176-
})? {
177-
Response::Entries { entries, .. } => entries
178-
.into_iter()
179-
.next()
180-
.ok_or_else(|| anyhow!("entry not found: {target_dn}"))?
181-
.attrs
182-
.get("objectClass")
183-
.cloned()
184-
.unwrap_or_default(),
185-
Response::SearchError { msg, .. } => return Err(anyhow!(msg)),
186-
other => {
187-
return Err(anyhow!(
188-
"unexpected response reading {target_dn}: {}",
189-
describe_response(&other)
190-
))
191-
}
192-
};
246+
// Resolve the target to a concrete DN (and its objectClass values, used for
247+
// samba detection) BEFORE prompting for the new password, so an unknown or
248+
// ambiguous username fails fast instead of after two password entries.
249+
let (target_dn, object_classes) = resolve_passwd_target(&worker, &profiles, target_arg)?;
193250
let is_samba = is_samba_account(&object_classes);
194251

252+
// Now that the target is known, prompt for the new password.
253+
let new_password = prompt(&target_dn)?;
254+
let target_dn = target_dn.as_str();
255+
195256
// Domain discovery is intentionally omitted from the password path:
196257
// `build_password_mods` derives `sambaNTPassword` from the cleartext alone and
197258
// never needs the domain SID / RID base (those are only required when
198259
// *creating* a samba account, not when re-setting its password). Keeping the
199260
// flow free of best-effort discovery keeps it correct and simple.
200261

201-
let mods = samba::password::build_password_mods(new_password, is_samba, now_unix_secs()?);
262+
let mods = samba::password::build_password_mods(&new_password, is_samba, now_unix_secs()?);
202263

203264
match worker.request(Request::Modify {
204265
id: 2,

src/main.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ enum Command {
3636
/// new password twice; updates `userPassword` and, for a `sambaSamAccount`,
3737
/// `sambaNTPassword` + `sambaPwdLastSet` in one atomic MODIFY.
3838
Passwd {
39-
/// Target entry DN, e.g. uid=alice,ou=people,dc=example,dc=org
40-
dn: String,
39+
/// Target username or full DN, e.g. `alice` or
40+
/// `uid=alice,ou=people,dc=example,dc=org`. A bare username is resolved
41+
/// against the configured profiles' search bases.
42+
user: String,
4143
},
4244
}
4345

@@ -88,9 +90,11 @@ fn main() -> Result<()> {
8890
let report: SchemaReport = edaptor::run_schema(config, password, &object_class)?;
8991
print_schema(&report);
9092
}
91-
Some(Command::Passwd { dn }) => {
92-
let new_password = prompt_new_password()?;
93-
let confirmation = edaptor::run_passwd(config, password, &dn, &new_password)?;
93+
Some(Command::Passwd { user }) => {
94+
let confirmation = edaptor::run_passwd(config, password, &user, |dn| {
95+
println!("Setting password for {dn}");
96+
prompt_new_password()
97+
})?;
9498
println!("{confirmation}");
9599
}
96100
}

0 commit comments

Comments
 (0)