mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 13:09:56 +00:00
2fa9877d85
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).
157 lines
4.4 KiB
Rust
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();
|
|
}
|