Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
#
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/roles-and-access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

## Endpoint access control

### Session management
Expand Down
63 changes: 63 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthIdentity>>,
Extension(database): Extension<Db>,
Json(req): Json<CreateUserRequest>,
) -> 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,
Expand Down
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
137 changes: 127 additions & 10 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,26 +513,75 @@ 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,
name: &str,
oidc_subject: Option<&str>,
default_role: &str,
groups: &[String],
) -> rusqlite::Result<User> {
create_if_missing: bool,
) -> rusqlite::Result<Option<User>> {
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<User> {
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
Expand Down Expand Up @@ -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"));
}
}
40 changes: 39 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 11 additions & 7 deletions src/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading