Files
rustguac/tests/cli_users.rs
T
Dave Kempe 2fa9877d85 oidc: bind users to their provider subject, match email ignoring case
Users are found by email, and with auto_create_users = false the email
becomes the gate. An email alone is not proof of identity, since some
providers let people set it, so a user is now bound to the OIDC subject
of their first login and a login with the same email but a different
subject is refused (and logged). A login the provider explicitly marks
email_verified: false is refused too.

New CLI-only command reset-oidc-subject releases the binding, for moving
to a different identity provider or a recreated provider account; it is
written to the CLI audit log.

Emails match ignoring case, so an admin typing JSmith@Example.com no
longer locks out a provider that sends jsmith@example.com, nor creates a
second account with auto-create on; a user cannot be added twice in
different case. add-user and POST /api/users check the email and name,
and create_user reports "already exists" as a type instead of matching
"UNIQUE" in the error text.

Tests for the login rules, and CLI integration tests for add-user and
reset-oidc-subject including their audit rows. Docs: how a login is
matched, moving providers, POST /api/users, the CLI commands, and the
login page message (a redirect, not a 403).
2026-10-18 13:23:14 +11:00

157 lines
4.4 KiB
Rust

//! CLI behaviour for `rustguac add-user` and `rustguac reset-oidc-subject`.
//!
//! Integration tests because they run the built binary (see
//! tests/cli_map_group.rs for why that has to happen here). Database
//! assertions go through rusqlite directly, since rustguac has no lib target.
use assert_cmd::Command;
use rusqlite::Connection;
/// Scratch directory for one test, removed on drop.
struct ScratchDir(std::path::PathBuf);
impl ScratchDir {
fn new(name: &str) -> Self {
let path =
std::env::temp_dir().join(format!("rustguac-cli-{}-{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("create scratch dir");
std::fs::write(path.join("config.toml"), "db_path = \"./rustguac.db\"\n")
.expect("write config");
ScratchDir(path)
}
fn conn(&self) -> Connection {
Connection::open(self.0.join("rustguac.db")).expect("open database")
}
fn run(&self, args: &[&str]) -> assert_cmd::assert::Assert {
let mut cmd = Command::cargo_bin("rustguac").unwrap();
cmd.current_dir(&self.0)
.args(["--config", "config.toml"])
.args(args)
.assert()
}
/// Every user as (email, role, oidc_subject), ordered by id.
fn users(&self) -> Vec<(String, String, Option<String>)> {
let conn = self.conn();
let mut stmt = conn
.prepare("SELECT email, role, oidc_subject FROM users ORDER BY id")
.expect("prepare");
let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.expect("query");
rows.map(|r| r.expect("row")).collect()
}
/// The newest CLI audit row as (action, target, details).
fn last_audit(&self) -> (String, String, Option<String>) {
self.conn()
.query_row(
"SELECT action, target, details FROM cli_audit_log ORDER BY id DESC LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.expect("an audit row should exist")
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn cli_add_user() {
let dir = ScratchDir::new("add-user");
dir.run(&[
"add-user",
"--email",
"JSmith@Example.com",
"--role",
"operator",
])
.success();
assert_eq!(
dir.users(),
vec![(
"JSmith@Example.com".to_string(),
"operator".to_string(),
None
)]
);
assert_eq!(
dir.last_audit(),
(
"add_user".to_string(),
"JSmith@Example.com".to_string(),
Some("role=operator".to_string())
)
);
// Logins match email ignoring case, so another case is the same user.
dir.run(&[
"add-user",
"--email",
"jsmith@example.com",
"--role",
"admin",
])
.failure();
// Not an email a provider would send.
dir.run(&[
"add-user",
"--email",
"bob smith@example.com",
"--role",
"viewer",
])
.failure();
dir.run(&["add-user", "--email", "bob", "--role", "viewer"])
.failure();
dir.run(&["add-user", "--email", "bob@example.com", "--role", "root"])
.failure();
assert_eq!(dir.users().len(), 1, "refused adds change nothing");
}
#[test]
fn cli_reset_oidc_subject() {
let dir = ScratchDir::new("reset-oidc-subject");
dir.run(&[
"add-user",
"--email",
"alice@example.com",
"--role",
"admin",
])
.success();
// Stand in for a first login, which records the provider's subject.
dir.conn()
.execute(
"UPDATE users SET oidc_subject = 'old-idp-alice' WHERE email = 'alice@example.com'",
[],
)
.unwrap();
dir.run(&["reset-oidc-subject", "--email", "ALICE@example.com"])
.success();
assert_eq!(
dir.users(),
vec![("alice@example.com".to_string(), "admin".to_string(), None)],
"the binding is gone and the role is untouched"
);
assert_eq!(
dir.last_audit(),
(
"reset_oidc_subject".to_string(),
"alice@example.com".to_string(),
None
)
);
dir.run(&["reset-oidc-subject", "--email", "nobody@example.com"])
.failure();
}