mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 13:09:56 +00:00
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).
This commit is contained in:
+10
@@ -418,6 +418,16 @@ Delete a recording file. Requires **admin** role.
|
||||
|
||||
List all OIDC users.
|
||||
|
||||
### `POST /api/users`
|
||||
|
||||
Add a user ahead of their first OIDC login, which is how anyone gets in when `[oidc] auto_create_users = false`.
|
||||
|
||||
```json
|
||||
{ "email": "jsmith@example.com", "role": "operator", "name": "J Smith" }
|
||||
```
|
||||
|
||||
`role` is one of `admin`, `poweruser`, `operator`, `viewer`; `name` is optional and replaced by the provider's name on login. The email must contain `@`, be at most 254 characters and have no spaces or control characters; the name at most 200 characters. Responses: `201` with the user; `400` for an invalid email, name or role; `409` if a user with that email already exists, ignoring case. See [How a login is matched to a user](roles-and-access-control.md#how-a-login-is-matched-to-a-user).
|
||||
|
||||
### `PUT /api/users/:email/role`
|
||||
|
||||
Set a user's role.
|
||||
|
||||
@@ -110,7 +110,7 @@ Enables OpenID Connect authentication. When configured, the web UI shows a login
|
||||
| `client_secret` | — | OIDC client secret (or use `OIDC_CLIENT_SECRET` env var) |
|
||||
| `redirect_uri` | — | Redirect URI: `https://your-host/auth/callback` (required) |
|
||||
| `default_role` | `operator` | Role assigned to new users on first login |
|
||||
| `auto_create_users` | `true` | Create an account on first OIDC login. When `false`, only users already in the database can log in; add them with `rustguac add-user`, `POST /api/users` or the Admin page |
|
||||
| `auto_create_users` | `true` | Create an account on first OIDC login. When `false`, only users already in the database can log in; add them with `rustguac add-user`, `POST /api/users` or the Admin page. See [Only users you have added](roles-and-access-control.md#only-users-you-have-added) |
|
||||
| `groups_claim` | `groups` | JWT claim name containing group memberships |
|
||||
| `extra_scopes` | `[]` | Additional OIDC scopes to request |
|
||||
| `ca_cert` | — | Path to CA certificate (PEM) for verifying the OIDC provider |
|
||||
|
||||
@@ -49,7 +49,33 @@ OIDC users are assigned a role through three mechanisms (in order of precedence)
|
||||
2. **Manual role assignment** — admins can set a user's role via CLI, API, or the Admin page.
|
||||
3. **Default role** — new users get the `default_role` from OIDC config on first login (default: `operator`).
|
||||
|
||||
With `auto_create_users = false` in `[oidc]`, rustguac does not create accounts on login: a user must be added first via `rustguac add-user --email <email> --role <role>`, `POST /api/users` (`{"email": "...", "role": "...", "name": "..."}`), or the Admin page. Anyone else who authenticates with the identity provider gets a 403. The pre-assigned role is kept; `default_role` is not applied, but group-to-role mappings still are.
|
||||
#### Only users you have added
|
||||
|
||||
With `auto_create_users = false` in `[oidc]`, rustguac does not create accounts on login: a user must be added first with `rustguac add-user --email <email> --role <role>`, `POST /api/users`, or **Add User** on the Admin page. Anyone else who authenticates with the identity provider is sent back to the login page with "You do not have an account here". The role the admin chose is kept; `default_role` is not applied, but group-to-role mappings still are.
|
||||
|
||||
```bash
|
||||
rustguac add-user --email jsmith@example.com --role operator --name "J Smith"
|
||||
```
|
||||
|
||||
Emails are matched ignoring case, so it does not matter whether the provider sends `JSmith@Example.com` or `jsmith@example.com`, and two users cannot differ only in case.
|
||||
|
||||
#### How a login is matched to a user
|
||||
|
||||
Users are found by email, but an email on its own is not proof of who someone is: some identity providers let people set or change their own address. So the first time a user logs in, rustguac records their provider account's **subject** (the OIDC `sub` claim, a permanent ID the provider never reuses), and from then on that user can only be signed in as by that same provider account. A login with the same email but a different subject is refused with "This sign-in does not match the account used here before", and logged as a warning. This applies whether or not `auto_create_users` is on.
|
||||
|
||||
A user an admin added who has not logged in yet has no subject, so their first login records it.
|
||||
|
||||
rustguac also refuses a login when the provider explicitly marks the email as unverified (`email_verified: false`). Providers that do not send the claim are trusted as before.
|
||||
|
||||
#### Moving to a different identity provider
|
||||
|
||||
Every user's subject changes when you switch providers (for example from JumpCloud to Entra ID), so after the switch everyone would be refused. Clear each user's recorded subject and their next login records the new one:
|
||||
|
||||
```bash
|
||||
rustguac reset-oidc-subject --email jsmith@example.com
|
||||
```
|
||||
|
||||
Their role, group mappings, tokens and credentials are kept. This is CLI-only on purpose: it needs shell access to the rustguac host, and each use is written to the CLI audit log. The same command fixes a single user whose provider account was deleted and recreated.
|
||||
|
||||
## Endpoint access control
|
||||
|
||||
@@ -89,6 +115,7 @@ With `auto_create_users = false` in `[oidc]`, rustguac does not create accounts
|
||||
| Endpoint | Required role |
|
||||
|----------|--------------|
|
||||
| `GET /api/users` | admin |
|
||||
| `POST /api/users` | admin |
|
||||
| `PUT /api/users/:email/role` | admin |
|
||||
| `DELETE /api/users/:email` | admin |
|
||||
| `POST /api/users/:email/disable` | admin |
|
||||
@@ -253,6 +280,14 @@ This means:
|
||||
# List all OIDC users
|
||||
rustguac list-users
|
||||
|
||||
# Add a user ahead of their first login (needed with auto_create_users = false)
|
||||
rustguac add-user --email user@example.com --role operator --name "A User"
|
||||
|
||||
# Forget the identity provider account a user is bound to, after a provider
|
||||
# migration or a recreated provider account (see "Moving to a different
|
||||
# identity provider" above)
|
||||
rustguac reset-oidc-subject --email user@example.com
|
||||
|
||||
# Set a user's role
|
||||
rustguac set-role --email user@example.com --role poweruser
|
||||
|
||||
|
||||
+14
-10
@@ -1597,12 +1597,9 @@ pub async fn create_user(
|
||||
}
|
||||
|
||||
let email = req.email.trim().to_string();
|
||||
if email.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "email is required"})),
|
||||
)
|
||||
.into_response();
|
||||
let name = req.name.trim().to_string();
|
||||
if let Err(msg) = db::check_user_email(&email).and_then(|_| db::check_user_name(&name)) {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
|
||||
}
|
||||
if !["admin", "poweruser", "operator", "viewer"].contains(&req.role.as_str()) {
|
||||
return (
|
||||
@@ -1613,15 +1610,22 @@ pub async fn create_user(
|
||||
}
|
||||
|
||||
let db_clone = database.clone();
|
||||
let name = req.name.trim().to_string();
|
||||
let role = req.role.clone();
|
||||
let admin = identity
|
||||
.as_ref()
|
||||
.map(|Extension(id)| id.display_name().to_string())
|
||||
.unwrap_or_default();
|
||||
let (log_email, log_role) = (email.clone(), role.clone());
|
||||
match tokio::task::spawn_blocking(move || db::create_user(&db_clone, &email, &name, &role))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(user)) => (StatusCode::CREATED, Json(json!(user))).into_response(),
|
||||
Ok(Err(e)) if e.to_string().contains("UNIQUE") => (
|
||||
Ok(Ok(user)) => {
|
||||
tracing::info!(admin = %admin, email = %log_email, role = %log_role, "user added");
|
||||
(StatusCode::CREATED, Json(json!(user))).into_response()
|
||||
}
|
||||
Ok(Err(db::CreateUserError::Exists)) => (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "user already exists"})),
|
||||
Json(json!({"error": "a user with that email already exists (emails match ignoring case)"})),
|
||||
)
|
||||
.into_response(),
|
||||
_ => (
|
||||
|
||||
@@ -513,10 +513,123 @@ impl std::fmt::Display for AuthError {
|
||||
|
||||
// ── User management ──
|
||||
|
||||
/// Record an OIDC login. Updates an existing user's profile and last_login_at.
|
||||
/// A user not yet in the database is created with `default_role` when
|
||||
/// `create_if_missing` is set; otherwise `Ok(None)` is returned and nothing is
|
||||
/// written (OIDC `auto_create_users = false`).
|
||||
/// Check an email an admin gives for a new user: what an identity provider
|
||||
/// could send, not just anything. Returns the reason it is refused.
|
||||
pub fn check_user_email(email: &str) -> Result<(), &'static str> {
|
||||
if email.is_empty() {
|
||||
return Err("email is required");
|
||||
}
|
||||
if email.len() > 254 {
|
||||
return Err("email is longer than 254 characters");
|
||||
}
|
||||
if email.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return Err("email must not contain spaces or control characters");
|
||||
}
|
||||
if !email.contains('@') {
|
||||
return Err("email must contain @");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a display name an admin gives for a new user.
|
||||
pub fn check_user_name(name: &str) -> Result<(), &'static str> {
|
||||
if name.chars().count() > 200 {
|
||||
return Err("name is longer than 200 characters");
|
||||
}
|
||||
if name.chars().any(|c| c.is_control()) {
|
||||
return Err("name must not contain control characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How an OIDC login matched the users table (`upsert_user`).
|
||||
#[derive(Debug)]
|
||||
pub enum OidcLogin {
|
||||
/// Logged in: the user's profile and last_login_at were updated, or the
|
||||
/// user was created.
|
||||
User(User),
|
||||
/// No user has this email, and creating one was not allowed
|
||||
/// (`auto_create_users = false`).
|
||||
NoAccount,
|
||||
/// The user with this email is bound to a different OIDC subject. Their
|
||||
/// identity provider account is not the one that logged in before, so
|
||||
/// the login is refused; `rustguac reset-oidc-subject` releases the
|
||||
/// binding after a provider migration.
|
||||
SubjectMismatch,
|
||||
/// More than one user has this email in a different case, so it is not
|
||||
/// clear who is logging in. Nothing was changed.
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
/// Which user an email names (`find_user_for_email`).
|
||||
enum EmailMatch {
|
||||
/// One user: their id and recorded OIDC subject.
|
||||
One(i64, Option<String>),
|
||||
None,
|
||||
/// Several users match ignoring case and none exactly.
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
/// The user an email names: an exact match, or else the one user whose email
|
||||
/// matches ignoring case (providers and admins do not always agree on case).
|
||||
fn find_user_for_email(conn: &Connection, email: &str) -> rusqlite::Result<EmailMatch> {
|
||||
let exact = conn.query_row(
|
||||
"SELECT id, oidc_subject FROM users WHERE email = ?1",
|
||||
params![email],
|
||||
|row| Ok(EmailMatch::One(row.get(0)?, row.get(1)?)),
|
||||
);
|
||||
match exact {
|
||||
Ok(found) => return Ok(found),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, oidc_subject FROM users WHERE lower(email) = lower(?1)")?;
|
||||
let mut rows: Vec<EmailMatch> = stmt
|
||||
.query_map(params![email], |row| {
|
||||
Ok(EmailMatch::One(row.get(0)?, row.get(1)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(match rows.len() {
|
||||
0 => EmailMatch::None,
|
||||
1 => rows.remove(0),
|
||||
_ => EmailMatch::Ambiguous,
|
||||
})
|
||||
}
|
||||
|
||||
fn user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<User> {
|
||||
conn.query_row(
|
||||
"SELECT id, email, name, oidc_subject, role, disabled, created_at, last_login_at, oidc_groups
|
||||
FROM users WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
email: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
oidc_subject: row.get(3)?,
|
||||
role: row.get(4)?,
|
||||
disabled: row.get::<_, i32>(5)? != 0,
|
||||
created_at: row.get(6)?,
|
||||
last_login_at: row.get(7)?,
|
||||
oidc_groups: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Record an OIDC login.
|
||||
///
|
||||
/// The user is found by email, ignoring case. A user who has logged in before
|
||||
/// is bound to the OIDC subject (`sub`) their provider sent then: a login
|
||||
/// with the same email but a different subject is refused
|
||||
/// (`SubjectMismatch`), because the email alone is not proof of identity (a
|
||||
/// provider may let people set it). A user who has not logged in yet, such
|
||||
/// as one an admin added, takes the subject of their first login.
|
||||
///
|
||||
/// A user not in the table is created with `default_role` when
|
||||
/// `create_if_missing` is set; otherwise `NoAccount` and nothing is written
|
||||
/// (OIDC `auto_create_users = false`).
|
||||
pub fn upsert_user(
|
||||
db: &Db,
|
||||
email: &str,
|
||||
@@ -525,82 +638,101 @@ pub fn upsert_user(
|
||||
default_role: &str,
|
||||
groups: &[String],
|
||||
create_if_missing: bool,
|
||||
) -> rusqlite::Result<Option<User>> {
|
||||
) -> rusqlite::Result<OidcLogin> {
|
||||
let groups_str = groups.join(",");
|
||||
let conn = db.lock().unwrap();
|
||||
if create_if_missing {
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, name, oidc_subject, role, oidc_groups)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
oidc_subject = COALESCE(excluded.oidc_subject, users.oidc_subject),
|
||||
oidc_groups = excluded.oidc_groups,
|
||||
last_login_at = datetime('now')",
|
||||
params![email, name, oidc_subject, default_role, groups_str],
|
||||
)?;
|
||||
} else {
|
||||
let changed = conn.execute(
|
||||
"UPDATE users SET
|
||||
name = ?2,
|
||||
oidc_subject = COALESCE(?3, oidc_subject),
|
||||
oidc_groups = ?4,
|
||||
last_login_at = datetime('now')
|
||||
WHERE email = ?1",
|
||||
params![email, name, oidc_subject, groups_str],
|
||||
)?;
|
||||
if changed == 0 {
|
||||
return Ok(None);
|
||||
let id = match find_user_for_email(&conn, email)? {
|
||||
EmailMatch::Ambiguous => return Ok(OidcLogin::Ambiguous),
|
||||
EmailMatch::One(id, recorded) => {
|
||||
if let (Some(recorded), Some(subject)) = (recorded.as_deref(), oidc_subject) {
|
||||
if recorded != subject {
|
||||
return Ok(OidcLogin::SubjectMismatch);
|
||||
}
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE users SET
|
||||
name = ?2,
|
||||
oidc_subject = COALESCE(?3, oidc_subject),
|
||||
oidc_groups = ?4,
|
||||
last_login_at = datetime('now')
|
||||
WHERE id = ?1",
|
||||
params![id, name, oidc_subject, groups_str],
|
||||
)?;
|
||||
id
|
||||
}
|
||||
EmailMatch::None if create_if_missing => {
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, name, oidc_subject, role, oidc_groups, last_login_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))",
|
||||
params![email, name, oidc_subject, default_role, groups_str],
|
||||
)?;
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
EmailMatch::None => return Ok(OidcLogin::NoAccount),
|
||||
};
|
||||
user_by_id(&conn, id).map(OidcLogin::User)
|
||||
}
|
||||
|
||||
/// Why `create_user` did not add a user.
|
||||
#[derive(Debug)]
|
||||
pub enum CreateUserError {
|
||||
/// A user with this email already exists, in any case.
|
||||
Exists,
|
||||
Db(rusqlite::Error),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for CreateUserError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
CreateUserError::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CreateUserError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CreateUserError::Exists => write!(f, "a user with that email already exists"),
|
||||
CreateUserError::Db(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
conn.query_row(
|
||||
"SELECT id, email, name, oidc_subject, role, disabled, created_at, last_login_at, oidc_groups
|
||||
FROM users WHERE email = ?1",
|
||||
params![email],
|
||||
|row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
email: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
oidc_subject: row.get(3)?,
|
||||
role: row.get(4)?,
|
||||
disabled: row.get::<_, i32>(5)? != 0,
|
||||
created_at: row.get(6)?,
|
||||
last_login_at: row.get(7)?,
|
||||
oidc_groups: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// Pre-provision a user so they can log in via OIDC when automatic account
|
||||
/// creation is disabled. The OIDC subject, name and groups are filled in on
|
||||
/// first login. Fails with a UNIQUE constraint error if the email exists.
|
||||
pub fn create_user(db: &Db, email: &str, name: &str, role: &str) -> rusqlite::Result<User> {
|
||||
/// first login. Refused if a user with the same email exists in any case,
|
||||
/// since logins match email ignoring case.
|
||||
pub fn create_user(db: &Db, email: &str, name: &str, role: &str) -> Result<User, CreateUserError> {
|
||||
let conn = db.lock().unwrap();
|
||||
let taken: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE lower(email) = lower(?1)",
|
||||
params![email],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if taken > 0 {
|
||||
return Err(CreateUserError::Exists);
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, name, role) VALUES (?1, ?2, ?3)",
|
||||
params![email, name, role],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT id, email, name, oidc_subject, role, disabled, created_at, last_login_at, oidc_groups
|
||||
FROM users WHERE email = ?1",
|
||||
params![email],
|
||||
|row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
email: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
oidc_subject: row.get(3)?,
|
||||
role: row.get(4)?,
|
||||
disabled: row.get::<_, i32>(5)? != 0,
|
||||
created_at: row.get(6)?,
|
||||
last_login_at: row.get(7)?,
|
||||
oidc_groups: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
Ok(user_by_id(&conn, conn.last_insert_rowid())?)
|
||||
}
|
||||
|
||||
/// Forget the OIDC subject a user is bound to, so their next login binds to
|
||||
/// whatever subject it brings. For moving to a different identity provider,
|
||||
/// where every user's subject changes. Returns the user's stored email, or
|
||||
/// `None` when no user (or, ignoring case, more than one) has this email.
|
||||
pub fn reset_oidc_subject(db: &Db, email: &str) -> rusqlite::Result<Option<String>> {
|
||||
let conn = db.lock().unwrap();
|
||||
match find_user_for_email(&conn, email)? {
|
||||
EmailMatch::One(id, _) => {
|
||||
conn.execute(
|
||||
"UPDATE users SET oidc_subject = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(Some(user_by_id(&conn, id)?.email))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an auth session for a user. Returns the plaintext session token
|
||||
@@ -1985,8 +2117,8 @@ mod tests {
|
||||
&groups,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.expect("user created");
|
||||
.unwrap();
|
||||
let user = logged_in(user);
|
||||
assert_eq!(user.role, "operator");
|
||||
assert_eq!(user.oidc_subject.as_deref(), Some("sub-a"));
|
||||
}
|
||||
@@ -2004,7 +2136,7 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(res.is_none());
|
||||
assert!(matches!(res, OidcLogin::NoAccount));
|
||||
assert!(get_user_by_email(&db, "x@example.com").is_err());
|
||||
}
|
||||
|
||||
@@ -2022,8 +2154,8 @@ mod tests {
|
||||
&groups,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
.expect("provisioned user logs in");
|
||||
.unwrap();
|
||||
let user = logged_in(user);
|
||||
// Pre-provisioned role is kept, default_role is not applied.
|
||||
assert_eq!(user.role, "viewer");
|
||||
assert_eq!(user.name, "P");
|
||||
@@ -2037,6 +2169,106 @@ mod tests {
|
||||
let db = test_db();
|
||||
create_user(&db, "d@example.com", "", "viewer").unwrap();
|
||||
let err = create_user(&db, "d@example.com", "", "admin").unwrap_err();
|
||||
assert!(err.to_string().contains("UNIQUE"));
|
||||
assert!(matches!(err, CreateUserError::Exists));
|
||||
// Logins match email ignoring case, so a different case is the same user.
|
||||
let err = create_user(&db, "D@Example.com", "", "admin").unwrap_err();
|
||||
assert!(matches!(err, CreateUserError::Exists));
|
||||
}
|
||||
|
||||
fn logged_in(o: OidcLogin) -> User {
|
||||
match o {
|
||||
OidcLogin::User(u) => u,
|
||||
other => panic!("expected a login, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn login(db: &Db, email: &str, sub: &str, create: bool) -> OidcLogin {
|
||||
upsert_user(db, email, "N", Some(sub), "viewer", &[], create).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_login_is_bound_to_the_first_subject() {
|
||||
// An admin-added user takes the subject of their first login...
|
||||
let db = test_db();
|
||||
create_user(&db, "alice@corp.example", "", "admin").unwrap();
|
||||
logged_in(login(&db, "alice@corp.example", "sub-alice", false));
|
||||
logged_in(login(&db, "alice@corp.example", "sub-alice", false));
|
||||
// ...and someone else's provider account carrying the same email is
|
||||
// refused, with auto-create on or off, and nothing is changed.
|
||||
for create in [false, true] {
|
||||
assert!(matches!(
|
||||
login(&db, "alice@corp.example", "sub-mallory", create),
|
||||
OidcLogin::SubjectMismatch
|
||||
));
|
||||
}
|
||||
let alice = get_user_by_email(&db, "alice@corp.example").unwrap();
|
||||
assert_eq!(alice.oidc_subject.as_deref(), Some("sub-alice"));
|
||||
assert_eq!(alice.role, "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_oidc_subject_lets_the_next_login_bind_again() {
|
||||
// A move to a different identity provider gives everyone a new
|
||||
// subject; the admin releases the old binding and the next login
|
||||
// records the new one.
|
||||
let db = test_db();
|
||||
logged_in(login(&db, "bob@corp.example", "old-idp-bob", true));
|
||||
assert!(matches!(
|
||||
login(&db, "bob@corp.example", "new-idp-bob", true),
|
||||
OidcLogin::SubjectMismatch
|
||||
));
|
||||
assert_eq!(
|
||||
reset_oidc_subject(&db, "BOB@corp.example")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("bob@corp.example")
|
||||
);
|
||||
let bob = logged_in(login(&db, "bob@corp.example", "new-idp-bob", true));
|
||||
assert_eq!(bob.oidc_subject.as_deref(), Some("new-idp-bob"));
|
||||
assert_eq!(
|
||||
reset_oidc_subject(&db, "nobody@corp.example").unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_login_matches_email_ignoring_case() {
|
||||
// The admin typed the email in one case and the provider sends
|
||||
// another: the same user logs in, with the role the admin chose, and
|
||||
// no second account appears.
|
||||
let db = test_db();
|
||||
create_user(&db, "JSmith@Example.com", "", "operator").unwrap();
|
||||
let u = logged_in(login(&db, "jsmith@example.com", "sub-j", false));
|
||||
assert_eq!(u.role, "operator");
|
||||
let u = logged_in(login(&db, "jsmith@example.com", "sub-j", true));
|
||||
assert_eq!(u.role, "operator");
|
||||
assert_eq!(list_users(&db).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_login_refuses_an_ambiguous_email() {
|
||||
// Two users already differ only in case (created before logins
|
||||
// ignored case): refuse rather than guess, and change nothing.
|
||||
let db = test_db();
|
||||
{
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, role) VALUES ('Ann@x.example', 'viewer')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, role) VALUES ('ANN@x.example', 'admin')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
login(&db, "ann@x.example", "sub-ann", true),
|
||||
OidcLogin::Ambiguous
|
||||
));
|
||||
assert_eq!(list_users(&db).unwrap().len(), 2);
|
||||
// An exact match is never ambiguous.
|
||||
logged_in(login(&db, "Ann@x.example", "sub-ann", false));
|
||||
}
|
||||
}
|
||||
|
||||
+41
-2
@@ -141,6 +141,15 @@ enum Command {
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Forget the identity provider account a user is bound to, so their next
|
||||
/// OIDC login binds to whichever account it comes from. For moving to a
|
||||
/// different identity provider, where every user's subject changes.
|
||||
ResetOidcSubject {
|
||||
/// User email
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
},
|
||||
|
||||
/// Set a user's role
|
||||
SetRole {
|
||||
/// User email
|
||||
@@ -287,6 +296,7 @@ async fn main() {
|
||||
Some(Command::AddUser { email, role, name }) => {
|
||||
cmd_add_user(&database, &email, &role, &name)
|
||||
}
|
||||
Some(Command::ResetOidcSubject { email }) => cmd_reset_oidc_subject(&database, &email),
|
||||
Some(Command::SetRole { email, role }) => cmd_set_role(&database, &email, &role),
|
||||
Some(Command::DisableUser { email }) => cmd_disable_user(&database, &email),
|
||||
Some(Command::DeleteUser { email }) => cmd_delete_user(&database, &email),
|
||||
@@ -534,17 +544,46 @@ fn cmd_list_users(database: &Db) {
|
||||
}
|
||||
|
||||
fn cmd_add_user(database: &Db, email: &str, role: &str, name: &str) {
|
||||
let email = email.trim();
|
||||
let name = name.trim();
|
||||
if !["admin", "poweruser", "operator", "viewer"].contains(&role) {
|
||||
eprintln!("Role must be admin, poweruser, operator, or viewer.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
if let Err(msg) = db::check_user_email(email).and_then(|_| db::check_user_name(name)) {
|
||||
eprintln!("Invalid user: {msg}.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
match db::create_user(database, email, name, role) {
|
||||
Ok(_) => {
|
||||
println!("User '{}' added with role '{}'.", email, role);
|
||||
audit_cli(database, "add_user", email, Some(&format!("role={}", role)));
|
||||
}
|
||||
Err(e) if e.to_string().contains("UNIQUE") => {
|
||||
eprintln!("User '{}' already exists.", email);
|
||||
Err(db::CreateUserError::Exists) => {
|
||||
eprintln!(
|
||||
"A user with email '{}' already exists (emails match ignoring case).",
|
||||
email
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_reset_oidc_subject(database: &Db, email: &str) {
|
||||
match db::reset_oidc_subject(database, email.trim()) {
|
||||
Ok(Some(stored)) => {
|
||||
println!(
|
||||
"OIDC subject for '{}' cleared. Their next login binds to the identity provider account it comes from.",
|
||||
stored
|
||||
);
|
||||
audit_cli(database, "reset_oidc_subject", &stored, None);
|
||||
}
|
||||
Ok(None) => {
|
||||
eprintln!("No single user with email '{}'.", email.trim());
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+28
-2
@@ -470,6 +470,14 @@ pub async fn callback(
|
||||
}
|
||||
};
|
||||
|
||||
// Users are found by email, so an email the provider itself says it has
|
||||
// not verified is not good enough to log anyone in. Providers that do not
|
||||
// send the claim at all are trusted as before.
|
||||
if claims.email().is_some() && claims.email_verified() == Some(false) {
|
||||
tracing::warn!("OIDC login rejected: the provider marked the email as unverified");
|
||||
return Redirect::to("/?sso_error=email_unverified").into_response();
|
||||
}
|
||||
|
||||
// Extract user info from claims
|
||||
let subject = claims.subject().to_string();
|
||||
let email: String = claims
|
||||
@@ -530,11 +538,29 @@ pub async fn callback(
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(user))) => user,
|
||||
Ok(Ok(None)) => {
|
||||
Ok(Ok(db::OidcLogin::User(user))) => user,
|
||||
Ok(Ok(db::OidcLogin::NoAccount)) => {
|
||||
tracing::warn!(email = %email, "OIDC login rejected: no account and auto_create_users is disabled");
|
||||
return Redirect::to("/?sso_error=no_account").into_response();
|
||||
}
|
||||
Ok(Ok(db::OidcLogin::SubjectMismatch)) => {
|
||||
// Same email, different provider account: possibly someone else
|
||||
// claiming the address, or a provider migration that needs
|
||||
// `rustguac reset-oidc-subject`.
|
||||
tracing::warn!(
|
||||
email = %email,
|
||||
"OIDC login rejected: this email is bound to a different OIDC subject \
|
||||
(if the identity provider changed, run rustguac reset-oidc-subject)"
|
||||
);
|
||||
return Redirect::to("/?sso_error=account_mismatch").into_response();
|
||||
}
|
||||
Ok(Ok(db::OidcLogin::Ambiguous)) => {
|
||||
tracing::warn!(
|
||||
email = %email,
|
||||
"OIDC login rejected: several users have this email in different case"
|
||||
);
|
||||
return Redirect::to("/?sso_error=account_mismatch").into_response();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("Failed to upsert user: {}", e);
|
||||
return (
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
var ssoMessages = {
|
||||
failed: 'SSO login failed — please try again.',
|
||||
no_account: 'You do not have an account here. Ask an administrator to add you.',
|
||||
account_mismatch: 'This sign-in does not match the account used here before. Ask an administrator.',
|
||||
email_unverified: 'Your identity provider has not verified your email address.',
|
||||
disabled: 'Your account is disabled. Contact an administrator.',
|
||||
unavailable: 'SSO provider is temporarily unavailable — retrying…'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! 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();
|
||||
}
|
||||
Reference in New Issue
Block a user