Private
Public Access
1
0

fix(security): restrict auth-config mutations to Admin role (#5)

Restrict manager-wide authentication configuration mutations (OIDC, SMTP, IP allowlist) to Admin role. Operators now receive 403 forbidden_role.

- New admin_required helper in settings.rs
- 4 gate changes: update_settings, discover_oidc, test_oidc, update_ip_whitelist
- 5 new AuditAction variants + migration 019
- SPA friendly error message on 403
- 3 admin_required unit tests pass (43/43)
- Full integration tests deferred to issue #15

Closes #5
This commit is contained in:
Draco-Lunaris-Echo
2026-06-03 09:16:41 -05:00
committed by GitHub
parent f58d7a6f17
commit 88b190ac8d
8 changed files with 436 additions and 12 deletions

128
crates/pm-web/src/routes/settings.rs Executable file → Normal file
View File

@ -180,6 +180,28 @@ fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>
Ok(())
}
/// Gate Manager-wide authentication configuration (OIDC, SMTP, IP allowlist,
/// OIDC discover/test) behind the **Admin** role. Operators can still
/// access per-host settings (see `write_access_required`).
///
/// Returns `403 forbidden_role` if the user is not an Admin. The distinct
/// error code (vs `forbidden` from `write_access_required`) lets the SPA
/// differentiate "you don't have write access at all" from "you have
/// write access but not for this specific resource".
///
/// See issue #5 and `tasks/authz-gate-spec.md` for the full design.
fn admin_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
if !auth.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(
json!({ "error": { "code": "forbidden_role", "message": "Admin role required to modify this resource" } }),
),
));
}
Ok(())
}
async fn load_system_config(
pool: &sqlx::PgPool,
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
@ -333,7 +355,7 @@ async fn update_settings(
auth: AuthUser,
Json(req): Json<UpdateSettingsRequest>,
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
// Update OIDC config
if let Some(oidc) = req.oidc {
@ -400,7 +422,7 @@ async fn update_settings(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::OidcConfigUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("oidc"),
@ -440,7 +462,7 @@ async fn update_settings(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::SmtpConfigUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("smtp"),
@ -485,7 +507,7 @@ async fn update_settings(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::IpWhitelistUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("ip_whitelist"),
@ -559,11 +581,11 @@ async fn update_settings(
// ============================================================
async fn discover_oidc(
State(_state): State<AppState>,
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<OidcDiscoveryRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
if req.discovery_url.is_empty() {
return Err((
@ -588,6 +610,20 @@ async fn discover_oidc(
match client.get(&req.discovery_url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: Value = resp.json().await.unwrap_or(json!({}));
// Audit log: Admin probed the OIDC discovery endpoint (issue #5).
// Non-fatal: log_event logs errors internally and does not propagate.
log_event(
&state.db,
AuditAction::OidcDiscoverPerformed,
Some(auth.user_id),
Some(&auth.username),
Some("oidc"),
Some(&req.discovery_url),
json!({ "discovery_url": req.discovery_url }),
None,
None,
)
.await;
Ok(Json(json!({
"success": true,
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
@ -620,7 +656,7 @@ async fn test_oidc(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
let row: Option<(bool, String, String)> = sqlx::query_as(
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
@ -679,6 +715,23 @@ async fn test_oidc(
"azure" => "Azure AD",
_ => "OIDC",
};
// Audit log: Admin tested the OIDC provider connection (issue #5).
// Non-fatal: log_event logs errors internally and does not propagate.
log_event(
&state.db,
AuditAction::OidcTestPerformed,
Some(auth.user_id),
Some(&auth.username),
Some("oidc"),
Some(&discovery_url),
json!({
"discovery_url": discovery_url,
"provider_type": provider_type,
}),
None,
None,
)
.await;
Ok(Json(json!({
"success": true,
"message": format!("{} provider verified successfully", provider_label),
@ -697,6 +750,9 @@ async fn test_oidc(
}
}
// Note: OIDC test audit log is emitted in the success path below.
// The above error cases don't persist, so no audit log is needed for them.
// ============================================================
// POST /api/v1/settings/azure-sso/test (backward-compatible alias)
// ============================================================
@ -899,7 +955,7 @@ async fn update_ip_whitelist(
auth: AuthUser,
Json(req): Json<IpWhitelistUpdate>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
// Validate each entry
for entry in &req.entries {
@ -921,7 +977,7 @@ async fn update_ip_whitelist(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::IpWhitelistUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("ip_whitelist"),
@ -975,3 +1031,57 @@ async fn audit_integrity(
})).collect::<Vec<_>>(),
})))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use pm_auth::jwt::AccessClaims;
use pm_auth::rbac::{AuthUser, UserRole};
use serde_json::json;
use uuid::Uuid;
/// Build a minimal `AuthUser` for role-gate testing.
/// The `admin_required` gate only inspects `auth.role`, so all other
/// fields can be placeholder values.
fn test_auth_user(role: UserRole) -> AuthUser {
let claims = AccessClaims {
sub: Uuid::new_v4().to_string(),
iat: 0,
exp: i64::MAX,
jti: Uuid::new_v4().to_string(),
role: role.as_str().to_string(),
username: "test-user".to_string(),
};
AuthUser {
user_id: Uuid::new_v4(),
username: "test-user".to_string(),
role,
claims,
}
}
#[test]
fn admin_required_admin_passes() {
let auth = test_auth_user(UserRole::Admin);
admin_required(&auth).expect("Admin should pass");
}
#[test]
fn admin_required_operator_denied() {
let auth = test_auth_user(UserRole::Operator);
let err = admin_required(&auth).expect_err("Operator should be denied");
let (status, body) = err;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["error"]["code"], "forbidden_role");
}
#[test]
fn admin_required_reporter_denied() {
let auth = test_auth_user(UserRole::Reporter);
let err = admin_required(&auth).expect_err("Reporter should be denied");
let (status, body) = err;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["error"]["code"], "forbidden_role");
}
}