From 9bfdc521550be6ee90efe2d966d8951210a37151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Dutka?= Date: Fri, 9 Oct 2026 11:44:03 +0200 Subject: [PATCH] With `[oidc] auto_create_users = false`, OIDC login no longer creates an account for a user who isn't in the database yet. Only users an admin has added beforehand can log in. The option defaults to true, so existing deployments behave as before. An admin can add users ahead of their first login with any of: - CLI: `rustguac add-user --email --role [--name ]` - API: `POST /api/users` (admin only), returns 201, or 409 if the user exists - Admin page: an "Add User" form under the users table A user added this way keeps the role the admin gave them; default_role is not applied. Group-to-role mappings still apply on every login. A rejected SSO login now redirects to the login page with an error code instead of returning JSON. This covers a user with no account (sso_error=no_account) and a disabled account (sso_error=disabled). SSO errors are now shown under the SSO button: they used to go into the API key form, which is hidden when SSO is on, so "SSO login failed" was never visible. The "provider unavailable" notice uses the same element, and all SSO messages are defined in one place in index.html. --- config.example.toml | 6 ++ docs/configuration.md | 1 + docs/roles-and-access-control.md | 2 + src/api.rs | 63 ++++++++++++++ src/config.rs | 7 ++ src/db.rs | 137 ++++++++++++++++++++++++++++--- src/main.rs | 40 ++++++++- src/oidc.rs | 18 ++-- static/admin.html | 35 ++++++++ static/index.html | 28 +++++-- 10 files changed, 311 insertions(+), 26 deletions(-) diff --git a/config.example.toml b/config.example.toml index 8f38324f..37d37120 100644 --- a/config.example.toml +++ b/config.example.toml @@ -147,6 +147,12 @@ web_allowed_networks = ["127.0.0.0/8", "::1/128"] # # Default: "operator" # default_role = "operator" # +# # Create an account automatically on first OIDC login (default: true). +# # Set to false to let in only users an admin has added beforehand +# # (`rustguac add-user --email ... --role ...`, POST /api/users, or the +# # Admin page). The email must match what the identity provider sends. +# auto_create_users = true +# # # OIDC claim name containing group memberships (default: "groups"). # groups_claim = "groups" # diff --git a/docs/configuration.md b/docs/configuration.md index fb92795f..03c711e1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,6 +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 | | `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 | diff --git a/docs/roles-and-access-control.md b/docs/roles-and-access-control.md index 6a977772..5a393b02 100644 --- a/docs/roles-and-access-control.md +++ b/docs/roles-and-access-control.md @@ -49,6 +49,8 @@ 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 --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. + ## Endpoint access control ### Session management diff --git a/src/api.rs b/src/api.rs index cfe236da..d0ba5d08 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1569,6 +1569,69 @@ pub async fn list_users( } } +#[derive(Deserialize)] +pub struct CreateUserRequest { + pub email: String, + #[serde(default)] + pub name: String, + pub role: String, +} + +/// POST /api/users — Pre-provision a user for OIDC login (needed when +/// `auto_create_users = false`). Admin only. +pub async fn create_user( + identity: Option>, + Extension(database): Extension, + Json(req): Json, +) -> impl IntoResponse { + if !identity + .as_ref() + .map(|Extension(id)| id.has_role("admin")) + .unwrap_or(false) + { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "admin role required"})), + ) + .into_response(); + } + + let email = req.email.trim().to_string(); + if email.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "email is required"})), + ) + .into_response(); + } + if !["admin", "poweruser", "operator", "viewer"].contains(&req.role.as_str()) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "role must be admin, poweruser, operator, or viewer"})), + ) + .into_response(); + } + + let db_clone = database.clone(); + let name = req.name.trim().to_string(); + let role = req.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") => ( + StatusCode::CONFLICT, + Json(json!({"error": "user already exists"})), + ) + .into_response(), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to create user"})), + ) + .into_response(), + } +} + #[derive(Deserialize)] pub struct SetRoleRequest { pub role: String, diff --git a/src/config.rs b/src/config.rs index ba89df9c..367bb878 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,11 @@ pub struct OidcConfig { pub redirect_uri: String, #[serde(default = "default_oidc_default_role")] pub default_role: String, + /// Create a user account on first OIDC login (default: true). When false, + /// only users already in the database (pre-provisioned with `add-user`, + /// `POST /api/users` or the Admin page) can log in. + #[serde(default = "default_true")] + pub auto_create_users: bool, /// Name of the OIDC claim containing group memberships (default: "groups"). #[serde(default = "default_groups_claim")] pub groups_claim: String, @@ -53,6 +58,7 @@ impl std::fmt::Debug for OidcConfig { ) .field("redirect_uri", &self.redirect_uri) .field("default_role", &self.default_role) + .field("auto_create_users", &self.auto_create_users) .field("groups_claim", &self.groups_claim) .field("extra_scopes", &self.extra_scopes) .field("tls_skip_verify", &self.tls_skip_verify) @@ -1535,6 +1541,7 @@ mod tests { client_secret: Some("sensitive-value".into()), redirect_uri: "https://console.example.com/oidc/callback".into(), default_role: default_oidc_default_role(), + auto_create_users: true, groups_claim: default_groups_claim(), extra_scopes: vec![], tls_skip_verify: false, diff --git a/src/db.rs b/src/db.rs index 6d75d4ad..3568bb1f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -513,7 +513,10 @@ impl std::fmt::Display for AuthError { // ── User management ── -/// Upsert a user from OIDC login. Creates on first login, updates last_login_at on subsequent. +/// 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`). pub fn upsert_user( db: &Db, email: &str, @@ -521,18 +524,64 @@ pub fn upsert_user( oidc_subject: Option<&str>, default_role: &str, groups: &[String], -) -> rusqlite::Result { + create_if_missing: bool, +) -> rusqlite::Result> { 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); + } + } + 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 { let conn = db.lock().unwrap(); 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], + "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 @@ -1922,4 +1971,72 @@ mod tests { let k = add_admin(&db, "none", None, None).unwrap(); assert!(validate_api_key(&db, &k, None).is_ok()); } + + #[test] + fn test_upsert_user_creates_when_allowed() { + let db = test_db(); + let groups = vec!["ops".to_string()]; + let user = upsert_user( + &db, + "a@example.com", + "A", + Some("sub-a"), + "operator", + &groups, + true, + ) + .unwrap() + .expect("user created"); + assert_eq!(user.role, "operator"); + assert_eq!(user.oidc_subject.as_deref(), Some("sub-a")); + } + + #[test] + fn test_upsert_user_without_create_rejects_unknown() { + let db = test_db(); + let res = upsert_user( + &db, + "x@example.com", + "X", + Some("sub-x"), + "admin", + &[], + false, + ) + .unwrap(); + assert!(res.is_none()); + assert!(get_user_by_email(&db, "x@example.com").is_err()); + } + + #[test] + fn test_upsert_user_without_create_logs_in_provisioned() { + let db = test_db(); + create_user(&db, "p@example.com", "", "viewer").unwrap(); + let groups = vec!["devs".to_string()]; + let user = upsert_user( + &db, + "p@example.com", + "P", + Some("sub-p"), + "admin", + &groups, + false, + ) + .unwrap() + .expect("provisioned user logs in"); + // Pre-provisioned role is kept, default_role is not applied. + assert_eq!(user.role, "viewer"); + assert_eq!(user.name, "P"); + assert_eq!(user.oidc_subject.as_deref(), Some("sub-p")); + assert_eq!(user.oidc_groups, "devs"); + assert!(user.last_login_at.is_some()); + } + + #[test] + fn test_create_user_duplicate_fails() { + 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")); + } } diff --git a/src/main.rs b/src/main.rs index 4bd2991b..922a24b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,6 +127,20 @@ enum Command { /// List all OIDC users ListUsers, + /// Add an OIDC user ahead of their first login (required to let anyone + /// in when `[oidc] auto_create_users = false`) + AddUser { + /// User email, exactly as the identity provider sends it + #[arg(long)] + email: String, + /// Role: admin, poweruser, operator, or viewer + #[arg(long)] + role: String, + /// Display name (replaced by the provider's name on login) + #[arg(long, default_value = "")] + name: String, + }, + /// Set a user's role SetRole { /// User email @@ -270,6 +284,9 @@ async fn main() { cmd_generate_cert(&hostname, &out_dir, &extra_sans); } Some(Command::ListUsers) => cmd_list_users(&database), + Some(Command::AddUser { email, role, name }) => { + cmd_add_user(&database, &email, &role, &name) + } 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), @@ -516,6 +533,27 @@ fn cmd_list_users(database: &Db) { } } +fn cmd_add_user(database: &Db, email: &str, role: &str, name: &str) { + if !["admin", "poweruser", "operator", "viewer"].contains(&role) { + eprintln!("Role must be admin, poweruser, operator, or viewer."); + 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); + std::process::exit(1); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } +} + fn cmd_set_role(database: &Db, email: &str, role: &str) { if !["admin", "poweruser", "operator", "viewer"].contains(&role) { eprintln!("Role must be admin, poweruser, operator, or viewer."); @@ -1202,7 +1240,7 @@ async fn run_server(config: Config, database: Db) { .route("/api/reports/top-users", get(api::report_top_users)) .route("/api/reports/summary", get(api::report_summary)) .route("/api/system/status", get(api::system_status)) - .route("/api/users", get(api::list_users)) + .route("/api/users", get(api::list_users).post(api::create_user)) .route("/api/users/{email}/role", put(api::set_user_role)) .route( "/api/users/{email}/sessions", diff --git a/src/oidc.rs b/src/oidc.rs index 07c70d5a..0bb40d88 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -508,8 +508,10 @@ pub async fn callback( _ => None, }; - // Upsert user in DB (sets default_role only on first login INSERT, not on subsequent updates) + // Upsert user in DB (sets default_role only on first login INSERT, not on subsequent updates). + // With auto_create_users = false, only already-provisioned users get through. let default_role = oidc.config.default_role.clone(); + let auto_create = oidc.config.auto_create_users; let db_clone = database.clone(); let email_clone = email.clone(); let name_clone = name.clone(); @@ -523,11 +525,16 @@ pub async fn callback( Some(&subject_clone), &default_role, &groups, + auto_create, ) }) .await { - Ok(Ok(user)) => user, + Ok(Ok(Some(user))) => user, + Ok(Ok(None)) => { + 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(Err(e)) => { tracing::error!("Failed to upsert user: {}", e); return ( @@ -562,11 +569,8 @@ pub async fn callback( }; if user.disabled { - return ( - StatusCode::FORBIDDEN, - axum::Json(json!({"error": "account is disabled"})), - ) - .into_response(); + tracing::warn!(email = %email, "OIDC login rejected: account is disabled"); + return Redirect::to("/?sso_error=disabled").into_response(); } // Create auth session diff --git a/static/admin.html b/static/admin.html index 5dea3b79..ed584178 100644 --- a/static/admin.html +++ b/static/admin.html @@ -44,6 +44,17 @@

Users

+
+ + + + +

Group-to-Role Mappings

OIDC groups are matched to roles on every login. The highest matching role wins.

@@ -310,6 +321,30 @@

Connections Audit Log

}); } + document.getElementById('add-user-btn').addEventListener('click', function() { + var email = document.getElementById('new-user-email').value.trim(); + var name = document.getElementById('new-user-name').value.trim(); + var role = document.getElementById('new-user-role').value; + if (!email) { showError('Email is required'); return; } + + fetch('/api/users', { + method: 'POST', + headers: apiHeaders({'Content-Type': 'application/json'}), + credentials: 'same-origin', + body: JSON.stringify({email: email, name: name, role: role}) + }) + .then(function(res) { + if (!res.ok) return res.json().then(function(d){ showError(d.error); throw new Error(); }); + return res.json(); + }) + .then(function() { + document.getElementById('new-user-email').value = ''; + document.getElementById('new-user-name').value = ''; + loadUsers(); + }) + .catch(function() {}); + }); + function td(text) { var el = document.createElement('td'); el.textContent = text || ''; diff --git a/static/index.html b/static/index.html index aece4ef0..5576e3db 100644 --- a/static/index.html +++ b/static/index.html @@ -24,13 +24,13 @@ color: var(--text-muted); cursor: not-allowed; } - #sso-unavailable { - display: none; + #sso-error { margin-top: var(--s-2); font-size: var(--fz-sm); color: var(--text-muted); text-align: center; } + #sso-error:empty { display: none; } .api-key-toggle { color: var(--text-muted); font-size: var(--fz-sm); @@ -77,7 +77,7 @@

rustguac