diff --git a/config.example.toml b/config.example.toml index 8f38324..37d3712 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 fb92795..03c711e 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 cd4f71b..1dc0058 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 3cf8bc3..cf2235b 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 ba89df9..367bb87 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 6d75d4a..3568bb1 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 436050a..ca34bde 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."); @@ -1247,7 +1285,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 07c70d5..0bb40d8 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 f173c83..bf6b4fe 100644 --- a/static/admin.html +++ b/static/admin.html @@ -45,6 +45,17 @@ +
+ + + + +

Group-to-Role Mappings

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

@@ -314,6 +325,30 @@ }); } + 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 aece4ef..5576e3d 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 @@