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:
committed by
GitHub
parent
f58d7a6f17
commit
88b190ac8d
12
crates/pm-core/src/audit.rs
Executable file → Normal file
12
crates/pm-core/src/audit.rs
Executable file → Normal file
@ -51,6 +51,12 @@ pub enum AuditAction {
|
|||||||
HealthCheckUpdated,
|
HealthCheckUpdated,
|
||||||
HealthCheckDeleted,
|
HealthCheckDeleted,
|
||||||
CertificateReissued,
|
CertificateReissued,
|
||||||
|
// Issue #5: Manager-wide auth-config mutations (Admin-only)
|
||||||
|
OidcConfigUpdated,
|
||||||
|
SmtpConfigUpdated,
|
||||||
|
IpWhitelistUpdated,
|
||||||
|
OidcTestPerformed,
|
||||||
|
OidcDiscoverPerformed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuditAction {
|
impl AuditAction {
|
||||||
@ -88,6 +94,12 @@ impl AuditAction {
|
|||||||
Self::HealthCheckUpdated => "health_check_updated",
|
Self::HealthCheckUpdated => "health_check_updated",
|
||||||
Self::HealthCheckDeleted => "health_check_deleted",
|
Self::HealthCheckDeleted => "health_check_deleted",
|
||||||
Self::CertificateReissued => "certificate_reissued",
|
Self::CertificateReissued => "certificate_reissued",
|
||||||
|
// Issue #5: Manager-wide auth-config mutations (Admin-only)
|
||||||
|
Self::OidcConfigUpdated => "oidc_config_updated",
|
||||||
|
Self::SmtpConfigUpdated => "smtp_config_updated",
|
||||||
|
Self::IpWhitelistUpdated => "ip_whitelist_updated",
|
||||||
|
Self::OidcTestPerformed => "oidc_test_performed",
|
||||||
|
Self::OidcDiscoverPerformed => "oidc_discover_performed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
crates/pm-web/src/routes/settings.rs
Executable file → Normal file
128
crates/pm-web/src/routes/settings.rs
Executable file → Normal file
@ -180,6 +180,28 @@ fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>
|
|||||||
Ok(())
|
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(
|
async fn load_system_config(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
|
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
|
||||||
@ -333,7 +355,7 @@ async fn update_settings(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<UpdateSettingsRequest>,
|
Json(req): Json<UpdateSettingsRequest>,
|
||||||
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
// Update OIDC config
|
// Update OIDC config
|
||||||
if let Some(oidc) = req.oidc {
|
if let Some(oidc) = req.oidc {
|
||||||
@ -400,7 +422,7 @@ async fn update_settings(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::OidcConfigUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("oidc"),
|
Some("oidc"),
|
||||||
@ -440,7 +462,7 @@ async fn update_settings(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::SmtpConfigUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("smtp"),
|
Some("smtp"),
|
||||||
@ -485,7 +507,7 @@ async fn update_settings(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::IpWhitelistUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("ip_whitelist"),
|
Some("ip_whitelist"),
|
||||||
@ -559,11 +581,11 @@ async fn update_settings(
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
async fn discover_oidc(
|
async fn discover_oidc(
|
||||||
State(_state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<OidcDiscoveryRequest>,
|
Json(req): Json<OidcDiscoveryRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
if req.discovery_url.is_empty() {
|
if req.discovery_url.is_empty() {
|
||||||
return Err((
|
return Err((
|
||||||
@ -588,6 +610,20 @@ async fn discover_oidc(
|
|||||||
match client.get(&req.discovery_url).send().await {
|
match client.get(&req.discovery_url).send().await {
|
||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
let body: Value = resp.json().await.unwrap_or(json!({}));
|
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!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
|
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
@ -620,7 +656,7 @@ async fn test_oidc(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
let row: Option<(bool, String, String)> = sqlx::query_as(
|
let row: Option<(bool, String, String)> = sqlx::query_as(
|
||||||
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
||||||
@ -679,6 +715,23 @@ async fn test_oidc(
|
|||||||
"azure" => "Azure AD",
|
"azure" => "Azure AD",
|
||||||
_ => "OIDC",
|
_ => "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!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": format!("{} provider verified successfully", provider_label),
|
"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)
|
// POST /api/v1/settings/azure-sso/test (backward-compatible alias)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -899,7 +955,7 @@ async fn update_ip_whitelist(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<IpWhitelistUpdate>,
|
Json(req): Json<IpWhitelistUpdate>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
write_access_required(&auth)?;
|
admin_required(&auth)?;
|
||||||
|
|
||||||
// Validate each entry
|
// Validate each entry
|
||||||
for entry in &req.entries {
|
for entry in &req.entries {
|
||||||
@ -921,7 +977,7 @@ async fn update_ip_whitelist(
|
|||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
AuditAction::ConfigChanged,
|
AuditAction::IpWhitelistUpdated,
|
||||||
Some(auth.user_id),
|
Some(auth.user_id),
|
||||||
Some(&auth.username),
|
Some(&auth.username),
|
||||||
Some("ip_whitelist"),
|
Some("ip_whitelist"),
|
||||||
@ -975,3 +1031,57 @@ async fn audit_integrity(
|
|||||||
})).collect::<Vec<_>>(),
|
})).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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -112,10 +112,10 @@ Security: JWT Bearer Token (except Public Endpoints)
|
|||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/settings` | Get system settings |
|
| GET | `/settings` | Get system settings |
|
||||||
| PUT | `/settings` | Update system settings |
|
| PUT | `/settings` | Update system settings **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||||
| POST | `/settings/smtp/test` | Test SMTP configuration |
|
| POST | `/settings/smtp/test` | Test SMTP configuration |
|
||||||
| POST | `/settings/sso/discover` | Discover OIDC provider config |
|
| POST | `/settings/sso/discover` | Discover OIDC provider config **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||||
| POST | `/settings/sso/test` | Test SSO connection |
|
| POST | `/settings/sso/test` | Test SSO connection **(Admin only — Operators receive `403 forbidden_role`)** |
|
||||||
| POST | `/settings/azure-sso/test` | Test Azure SSO compatibility |
|
| POST | `/settings/azure-sso/test` | Test Azure SSO compatibility |
|
||||||
| POST | `/settings/audit-integrity` | Verify audit log integrity |
|
| POST | `/settings/audit-integrity` | Verify audit log integrity |
|
||||||
|
|
||||||
|
|||||||
@ -85,6 +85,7 @@ verifying that all mandated security controls are implemented and operational.
|
|||||||
| Admin: full rights | ✅ Verified | Admin role bypasses group scoping; access to all resources |
|
| Admin: full rights | ✅ Verified | Admin role bypasses group scoping; access to all resources |
|
||||||
| Operator: group-scoped | ✅ Verified | Operators can only manage hosts in their assigned groups; middleware enforces on every request |
|
| Operator: group-scoped | ✅ Verified | Operators can only manage hosts in their assigned groups; middleware enforces on every request |
|
||||||
| RBAC middleware | ✅ Verified | Axum middleware extracts role from JWT; enforces before route handler execution |
|
| RBAC middleware | ✅ Verified | Axum middleware extracts role from JWT; enforces before route handler execution |
|
||||||
|
| **Manager-wide auth config is Admin-only (issue #5 fix)** | ✅ Verified | `admin_required` gate in `crates/pm-web/src/routes/settings.rs` restricts `update_settings` (OIDC/SMTP), `discover_oidc`, `test_oidc`, and `update_ip_whitelist` to Admin role. Operators receive `403 forbidden_role`. All mutations write audit events (`OidcConfigUpdated`, `SmtpConfigUpdated`, `IpWhitelistUpdated`, `OidcTestPerformed`, `OidcDiscoverPerformed`) via `log_event` in `crates/pm-core/src/audit.rs`. SPA shows friendly error: "Only Admins can modify authentication configuration. Contact an Admin to make this change." Verified by 3 `admin_required` unit tests (Admin passes, Operator denied, Reporter denied) and manual code review of 4 gate changes. Full integration tests deferred to [issue #15](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/15). |
|
||||||
|
|
||||||
### 2.5 Azure SSO
|
### 2.5 Azure SSO
|
||||||
| Control | Status | Evidence |
|
| Control | Status | Evidence |
|
||||||
|
|||||||
@ -99,6 +99,11 @@ export default function SettingsPage() {
|
|||||||
const { data } = await settingsApi.discoverOidc(oidc.discovery_url)
|
const { data } = await settingsApi.discoverOidc(oidc.discovery_url)
|
||||||
setDiscoveryResult(data)
|
setDiscoveryResult(data)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
const axiosErr = err as AxiosError
|
||||||
|
if (axiosErr.response?.status === 403) {
|
||||||
|
setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: 'Only Admins can modify authentication configuration. Contact an Admin to make this change.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const msg = err instanceof Error ? err.message : 'Discovery failed'
|
const msg = err instanceof Error ? err.message : 'Discovery failed'
|
||||||
setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: msg })
|
setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: msg })
|
||||||
} finally {
|
} finally {
|
||||||
@ -151,6 +156,10 @@ export default function SettingsPage() {
|
|||||||
setSuccess('Settings saved successfully')
|
setSuccess('Settings saved successfully')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const axiosErr = err as AxiosError<{ error?: { message?: string } }>
|
const axiosErr = err as AxiosError<{ error?: { message?: string } }>
|
||||||
|
if (axiosErr.response?.status === 403) {
|
||||||
|
setError('Only Admins can modify authentication configuration. Contact an Admin to make this change.')
|
||||||
|
return
|
||||||
|
}
|
||||||
const msg =
|
const msg =
|
||||||
axiosErr.response?.data?.error?.message ??
|
axiosErr.response?.data?.error?.message ??
|
||||||
(err instanceof Error ? err.message : 'Failed to save settings')
|
(err instanceof Error ? err.message : 'Failed to save settings')
|
||||||
|
|||||||
12
migrations/019_auth_config_audit_actions.sql
Normal file
12
migrations/019_auth_config_audit_actions.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Migration: 019_auth_config_audit_actions
|
||||||
|
-- Description: Add audit_action enum values for Manager-wide auth-config
|
||||||
|
-- mutations (issue #5). These are gated behind Admin role
|
||||||
|
-- and audit-logged with the acting user, the keys changed,
|
||||||
|
-- and (for OIDC) a flag indicating whether client_secret was
|
||||||
|
-- rotated (the secret value itself is never logged).
|
||||||
|
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_config_updated';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'smtp_config_updated';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'ip_whitelist_updated';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_test_performed';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_discover_performed';
|
||||||
230
tasks/authz-gate-spec.md
Normal file
230
tasks/authz-gate-spec.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# Authz Gate — Admin-Only Manager-Wide Configuration (Issue #5)
|
||||||
|
|
||||||
|
**Spec version:** v0.1.0
|
||||||
|
**Status:** Draft — awaiting Kelly sign-off
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**GitHub issue:** [#5](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Restrict mutations of Manager-wide authentication configuration to the **Admin** role only. Specifically:
|
||||||
|
|
||||||
|
- **OIDC provider config** (`discovery_url`, `client_id`, `client_secret`, `redirect_uri`, `scopes`)
|
||||||
|
- **SMTP config** (host, credentials, from address, recipients)
|
||||||
|
- **IP allowlist** (the in-memory `AuthConfig.ip_whitelist`)
|
||||||
|
- **OIDC discover / test handlers** (`discover_oidc`, `test_oidc`) — these probe the IdP and reveal config details
|
||||||
|
|
||||||
|
The **Operator** role is restricted to **per-host settings** for hosts in their scope (hosts with no group, or hosts in groups the operator is a member of). Operators MUST NOT be able to alter Manager-wide auth configuration. The **Reporter** role remains read-only across the board.
|
||||||
|
|
||||||
|
Audit-log every accepted change to the above with the acting user, action, and key. Return `403 forbidden_role` to Operators who attempt these mutations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Non-Goals
|
||||||
|
|
||||||
|
- **Per-host group membership scoping** for Operators is **out of scope**. Today, Operator mutations are gated by `can_write()` (`Admin | Operator`); the per-host case relies on the route being "general write" and assumes Operators manage whatever hosts are assigned to them. A follow-up issue will add explicit per-host/group checks (e.g., `assert_operator_can_write_host(operator_id, host_id)`). For this issue, we only fix the Manager-wide auth-config leak.
|
||||||
|
- **Role changes in the SPA** (e.g., hiding OIDC fields entirely for Operators) are out of scope per Kelly's Q4 = A. We show a friendly error message instead.
|
||||||
|
- **Audit log changes** (new enum values, new columns) are out of scope. The existing `audit_log` table + `audit_action` enum + `pm-core::audit` module are sufficient. If we need a new audit action (e.g., `oidc_config_updated`), we add it as a migration in this PR.
|
||||||
|
- **Refactoring `write_access_required`** out of `settings.rs` is out of scope. We add a new `admin_required` helper alongside it.
|
||||||
|
- **Removing `can_write()`** entirely is out of scope. It's still correct for the per-host case (until the per-host scoping follow-up lands).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design Decisions (Kelly sign-off)
|
||||||
|
|
||||||
|
| # | Question | Answer |
|
||||||
|
|---|----------|--------|
|
||||||
|
| **Q1** | Helper function vs inline check? | **A.** New `admin_required` helper in `settings.rs` (matches local `write_access_required` pattern, single point of change for the 403 error format). |
|
||||||
|
| **Q2** | Should `discover_oidc` / `test_oidc` be Admin-only? | **Yes — Admin-only.** Probing the IdP can leak the resolved `client_id`, scopes, and other details an Operator shouldn't see. The role model is: **Admin** can change Manager-wide config (OIDC, SMTP, IP allowlist) + everything else; **Operator** can only manage per-host settings for hosts in their scope; **Reporter** is read-only across the board. |
|
||||||
|
| **Q3** | Audit log destination? | **A.** Use the existing `audit_log` table via `pm-core::audit`. New `audit_action` enum values added via migration: `oidc_config_updated`, `smtp_config_updated`, `ip_whitelist_updated`, `oidc_test_performed`, `oidc_discover_performed`. |
|
||||||
|
| **Q4** | SPA UX for 403? | **A.** Friendly error message in `SettingsPage.tsx`: "Only Admins can modify authentication configuration. Contact an Admin to make this change." Matches the SPA's existing error-handling pattern. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Design
|
||||||
|
|
||||||
|
### 4.1 New `admin_required` helper (settings.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Placed in `crates/pm-web/src/routes/settings.rs` immediately after the existing `write_access_required` helper (~line 173). Uses a distinct error code (`forbidden_role` vs `forbidden`) so the SPA can differentiate "you don't have write access at all" from "you have write access but not for this specific resource".
|
||||||
|
|
||||||
|
### 4.2 Routes that change (4 in `settings.rs`)
|
||||||
|
|
||||||
|
| Handler | Current gate | New gate | Audit action |
|
||||||
|
|---------|--------------|----------|--------------|
|
||||||
|
| `update_settings` (line 336) | `write_access_required` | `admin_required` | `oidc_config_updated` and/or `smtp_config_updated` |
|
||||||
|
| `update_ip_whitelist` (line 902) | `write_access_required` | `admin_required` | `ip_whitelist_updated` |
|
||||||
|
| `discover_oidc` (line 561) | `write_access_required` | `admin_required` | `oidc_discover_performed` |
|
||||||
|
| `test_oidc` (line 619) | `write_access_required` | `admin_required` | `oidc_test_performed` |
|
||||||
|
|
||||||
|
**Non-changes:** The other 6+ handlers in `settings.rs` that use `write_access_required` for non-auth config (e.g., general `system_config` settings, health check settings, maintenance window settings) **stay as `write_access_required`**. The fix is targeted to the 4 auth-config handlers. The per-host settings endpoints in other route files (e.g., `host_groups.rs`, `hosts.rs`) are also unaffected — those are the per-host scope that Operators can keep accessing.
|
||||||
|
|
||||||
|
### 4.3 Audit log integration
|
||||||
|
|
||||||
|
Use the existing `crates/pm-core/src/audit.rs` module. New `audit_action` enum values are added via a migration (file `019_auth_config_audit_actions.sql`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: 019_auth_config_audit_actions
|
||||||
|
-- Description: Add audit_action enum values for auth-config mutations.
|
||||||
|
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_config_updated';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'smtp_config_updated';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'ip_whitelist_updated';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_test_performed';
|
||||||
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_discover_performed';
|
||||||
|
```
|
||||||
|
|
||||||
|
Each handler calls the audit log AFTER a successful mutation, with the acting user_id, the action, the config key(s) changed, and an optional hash of old/new values. The `pm-core::audit` module already provides the `write_audit_event()` function (see `audit.rs:179`). The integration looks like:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In update_settings, after a successful OIDC config update:
|
||||||
|
crate::audit::write_audit_event(
|
||||||
|
pool,
|
||||||
|
auth.user_id,
|
||||||
|
AuditAction::OidcConfigUpdated,
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"keys_changed": ["oidc_discovery_url", "oidc_client_id"],
|
||||||
|
"client_secret_changed": req.oidc.client_secret.as_ref().map(|s| !s.is_empty()).unwrap_or(false),
|
||||||
|
})),
|
||||||
|
).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
`write_audit_event` is async and uses the existing hash-chained `INSERT INTO audit_log` (audit.rs:179). Failures to write the audit log are logged via `tracing::error!` but DO NOT block the operation (the config change is already committed to `system_config` and possibly the in-memory `AuthConfig`). This matches the pattern used by the existing IP-whitelist path (line 956) and other audited handlers.
|
||||||
|
|
||||||
|
**Client secret handling:** If the request contains a new `client_secret`, log `client_secret_changed: true` (NOT the secret itself) so the audit trail records that a secret was rotated, but the secret value never touches the audit log.
|
||||||
|
|
||||||
|
### 4.4 SPA error message (SettingsPage.tsx)
|
||||||
|
|
||||||
|
The current `SettingsPage` likely shows the raw error message from the backend. Update the error handler to detect `error.code === 'forbidden_role'` and show a friendly message:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as AxiosError<{ error: { code: string; message: string } }>;
|
||||||
|
const code = error.response?.data?.error?.code;
|
||||||
|
if (code === 'forbidden_role') {
|
||||||
|
setError('Only Admins can modify authentication configuration. Contact an Admin to make this change.');
|
||||||
|
} else {
|
||||||
|
setError(error.response?.data?.error?.message || 'Failed to save settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The other 3 auth-config endpoints (OIDC, IP allowlist, test/discover) follow the same pattern. The change is localized to the 4 catch blocks in `SettingsPage` that correspond to these calls.
|
||||||
|
|
||||||
|
### 4.5 Test plan (unit + integration)
|
||||||
|
|
||||||
|
**Unit tests in `settings.rs` (cfg(test) module):**
|
||||||
|
|
||||||
|
1. `admin_required_admin_passes` — AuthUser with role Admin → returns Ok(())
|
||||||
|
2. `admin_required_operator_denied` — AuthUser with role Operator → returns Err with status 403 and code `forbidden_role`
|
||||||
|
3. `admin_required_reporter_denied` — AuthUser with role Reporter → returns Err with status 403 and code `forbidden_role`
|
||||||
|
|
||||||
|
**Integration tests for the 4 routes (using the existing test harness pattern from `sso_handoff_exchange_inner`):**
|
||||||
|
|
||||||
|
4. `update_settings_operator_denied` — POST as Operator with OIDC fields → 403 `forbidden_role`
|
||||||
|
5. `update_settings_admin_allowed` — POST as Admin with OIDC fields → 200 + audit row written
|
||||||
|
6. `update_ip_whitelist_operator_denied` — POST as Operator → 403 `forbidden_role`
|
||||||
|
7. `update_ip_whitelist_admin_allowed` — POST as Admin → 200 + audit row written + in-memory `AuthConfig.ip_whitelist` updated
|
||||||
|
8. `discover_oidc_operator_denied` — POST as Operator → 403 `forbidden_role`
|
||||||
|
9. `discover_oidc_admin_allowed` — POST as Admin → 200 + audit row written
|
||||||
|
10. `test_oidc_operator_denied` — POST as Operator → 403 `forbidden_role`
|
||||||
|
11. `test_oidc_admin_allowed` — POST as Admin → 200 + audit row written
|
||||||
|
|
||||||
|
**SPA test (Vitest, following the pattern from issue #4):**
|
||||||
|
|
||||||
|
12. `settings_page_forbidden_role_shows_friendly_message` — mock a 403 with code `forbidden_role` and assert the friendly message is shown
|
||||||
|
|
||||||
|
**Audit log assertion (in integration tests 5, 7, 9, 11):** Query `SELECT action, user_id, details FROM audit_log WHERE action = '<expected>'` and assert the row exists with the correct user_id and a non-null details JSONB.
|
||||||
|
|
||||||
|
### 4.6 Files changed
|
||||||
|
|
||||||
|
**Backend (5 files):**
|
||||||
|
- `crates/pm-web/src/routes/settings.rs` — new `admin_required` helper, 4 handler gate changes, 4 audit log calls, 11 tests
|
||||||
|
- `migrations/019_auth_config_audit_actions.sql` — new file, 5 enum values
|
||||||
|
- `crates/pm-core/src/audit.rs` (or wherever `AuditAction` is defined) — add 5 new enum variants
|
||||||
|
- `crates/pm-core/src/audit.rs` (or wherever `write_audit_event` is defined) — no API change, just verify the existing function supports the new action types
|
||||||
|
- `docs/security-review.md` — update §2.3 (Authorization / RBAC) with the new control row
|
||||||
|
- `docs/REST_API.md` — annotate the 4 affected endpoints with "Admin only"
|
||||||
|
|
||||||
|
**Frontend (1 file):**
|
||||||
|
- `frontend/src/pages/SettingsPage.tsx` — friendly error message in 4 catch blocks
|
||||||
|
- `frontend/src/pages/__tests__/SettingsPage.test.tsx` — new file, 1 test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `PUT /api/v1/settings` with OIDC fields returns 403 `forbidden_role` for Operator and 200 for Admin.
|
||||||
|
- [ ] `PUT /api/v1/settings` with SMTP fields returns 403 `forbidden_role` for Operator and 200 for Admin.
|
||||||
|
- [ ] `PUT /api/v1/settings/ip-whitelist` returns 403 `forbidden_role` for Operator and 200 for Admin.
|
||||||
|
- [ ] `POST /api/v1/settings/oidc/discover` returns 403 `forbidden_role` for Operator and 200 for Admin.
|
||||||
|
- [ ] `POST /api/v1/settings/oidc/test` returns 403 `forbidden_role` for Operator and 200 for Admin.
|
||||||
|
- [ ] Each successful mutation writes a row to `audit_log` with the correct `audit_action`, the acting `user_id`, and a non-null `details` JSONB.
|
||||||
|
- [ ] Reporter is unaffected (still read-only).
|
||||||
|
- [ ] The SPA shows the friendly "Only Admins..." error message when it receives a 403 `forbidden_role`.
|
||||||
|
- [ ] `cargo fmt --check --all`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `npx eslint --max-warnings 0`, `npm test` all pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Risk Analysis
|
||||||
|
|
||||||
|
**Risk: SPA regression — friendly error message doesn't trigger in some cases.**
|
||||||
|
Mitigation: The new test (`settings_page_forbidden_role_shows_friendly_message`) mocks a 403 with `error.code === 'forbidden_role'` and asserts the message. If the backend returns a different code, the SPA falls through to the raw error path (preserving existing behavior). Worst case: Operators see a raw 403 message instead of the friendly one — same security outcome, slightly worse UX.
|
||||||
|
|
||||||
|
**Risk: Audit log failures block the operation.**
|
||||||
|
Mitigation: `write_audit_event` returns a `Result` but the handlers log+continue on error (same pattern as the existing IP-whitelist path at line 956). The config change is already committed to `system_config` and possibly the in-memory `AuthConfig` before the audit log call. A failed audit log write is a serious problem (loses accountability) but is recoverable from the database state.
|
||||||
|
|
||||||
|
**Risk: Operators lose legitimate workflows that relied on the old gate.**
|
||||||
|
Mitigation: The 4 affected routes are Manager-wide auth config. Operators do not have a legitimate need to change these (per Kelly's role model: "The operator role should only be able to manage per host settings for hosts that have no group or the operator is in they're group"). No legitimate Operator workflow breaks. The 6+ other `write_access_required` handlers in `settings.rs` (health check, maintenance window, etc.) are unchanged.
|
||||||
|
|
||||||
|
**Risk: Existing `write_access_required` callers still let Operators mutate non-auth config they shouldn't.**
|
||||||
|
Mitigation: This is acknowledged as out of scope. The non-auth settings that `write_access_required` still gates (e.g., health check settings, maintenance window settings) are arguably Manager-wide too, but the issue body only flags the 4 auth-config handlers. A follow-up issue will audit the other 6+ handlers and decide which are Manager-wide (Admin-only) vs per-host (Operator-allowed via group membership).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Documentation
|
||||||
|
|
||||||
|
- **docs/security-review.md** §2.3 (Authorization / RBAC): add 2 new rows:
|
||||||
|
- "Manager-wide auth config (OIDC, SMTP, IP allowlist) is Admin-only" with evidence pointing to `admin_required` helper and 11 tests
|
||||||
|
- "Audit log captures every auth-config mutation with the acting user" with evidence pointing to the 5 new `audit_action` enum values and the `write_audit_event` calls
|
||||||
|
- **docs/REST_API.md**: annotate the 4 affected endpoints with "🔒 Admin only" and link to the role model
|
||||||
|
- **tasks/lessons.md**: add a project-specific lesson about the role model (Admin = Manager-wide, Operator = per-host, Reporter = read-only) so future issues get it right
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Follow-ups (out of scope for this PR)
|
||||||
|
|
||||||
|
1. **Per-host group membership scoping** for Operators — currently `can_write()` is a coarse "Admin or Operator" check. The follow-up adds explicit `assert_operator_can_write_host(operator_id, host_id)` that checks the host's group membership against the operator's group memberships. This is the proper fix for the "Operator can only manage per-host settings for hosts in their scope" requirement.
|
||||||
|
2. **Audit the other 6+ `write_access_required` handlers in `settings.rs`** to determine which are Manager-wide (Admin-only) vs per-host (Operator-allowed). Some likely candidates for Admin-only: system name, default timezone, default maintenance window. Some likely candidates for Operator-allowed: host label assignments, per-host health check targets, per-host maintenance window assignments.
|
||||||
|
3. **Hide auth-config fields in the SPA for Operators** — once the role model is settled, the SPA can conditionally render the OIDC/SMTP/IP allowlist sections only for Admins, instead of showing the fields and rejecting on save.
|
||||||
|
4. **Promote `admin_required` to `pm-auth`** as a shared helper alongside `Role::is_admin`, if the codebase grows more admin-only routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Sign-off
|
||||||
|
|
||||||
|
- [ ] Kelly approves spec (Q1=A, Q2=Admin-only, Q3=A, Q4=A confirmed)
|
||||||
|
- [ ] Echo implements Phase 1 (admin_required helper + 3 unit tests)
|
||||||
|
- [ ] Echo implements Phase 2 (4 handler gate changes + audit log calls)
|
||||||
|
- [ ] Echo implements Phase 3 (11 backend integration tests)
|
||||||
|
- [ ] Echo implements Phase 4 (SPA error message + 1 test)
|
||||||
|
- [ ] Echo implements Phase 5 (docs updates)
|
||||||
|
- [ ] Echo implements Phase 6 (review + commit + push + PR + comment on issue #5)
|
||||||
@ -209,3 +209,53 @@ _(filled in at completion)_
|
|||||||
- [x] 5c: Render pending hosts in the table with highlight styling
|
- [x] 5c: Render pending hosts in the table with highlight styling
|
||||||
- [x] 5d: Add Approve/Deny action buttons to pending host rows
|
- [x] 5d: Add Approve/Deny action buttons to pending host rows
|
||||||
- [x] 5e: Implement "merge/overwrite" interactive modal for `fqdn`/`ip_address` collisions on approval
|
- [x] 5e: Implement "merge/overwrite" interactive modal for `fqdn`/`ip_address` collisions on approval
|
||||||
|
|
||||||
|
## Issue #5: Admin-Only Manager-Wide Configuration (Authz Gate)
|
||||||
|
|
||||||
|
**Spec:** [tasks/authz-gate-spec.md](authz-gate-spec.md) (v0.1.0)
|
||||||
|
**Branch:** `fix/5-operator-can-modify-auth-config`
|
||||||
|
**Status:** Draft spec — awaiting Kelly sign-off
|
||||||
|
|
||||||
|
### Phase 1: admin_required helper + 3 unit tests
|
||||||
|
- 1a: Add `admin_required` helper in `crates/pm-web/src/routes/settings.rs` (after `write_access_required` ~line 173). Returns 403 with code `forbidden_role` if not Admin.
|
||||||
|
- 1b: Add 3 unit tests in cfg(test) module: `admin_required_admin_passes`, `admin_required_operator_denied`, `admin_required_reporter_denied`.
|
||||||
|
- 1c: Run `cargo test -p pm-web --bins --tests` and confirm green.
|
||||||
|
|
||||||
|
### Phase 2: Gate changes + audit log calls
|
||||||
|
- 2a: Replace `write_access_required` with `admin_required` in `update_settings` (line 336).
|
||||||
|
- 2b: Replace `write_access_required` with `admin_required` in `update_ip_whitelist` (line 902).
|
||||||
|
- 2c: Replace `write_access_required` with `admin_required` in `discover_oidc` (line 561).
|
||||||
|
- 2d: Replace `write_access_required` with `admin_required` in `test_oidc` (line 619).
|
||||||
|
- 2e: Create `migrations/019_auth_config_audit_actions.sql` with 5 new enum values.
|
||||||
|
- 2f: Add 5 new variants to the `AuditAction` enum in `crates/pm-core/src/audit.rs` (or wherever defined).
|
||||||
|
- 2g: Add `write_audit_event` calls in each of the 4 handlers, after successful mutations.
|
||||||
|
- 2h: Run `cargo fmt --check --all`, `cargo clippy --all-targets -- -D warnings`, `cargo test -p pm-web --bins --tests` and confirm clean.
|
||||||
|
|
||||||
|
### Phase 3: Integration tests (8 new)
|
||||||
|
- 3a: `update_settings_operator_denied` — POST as Operator with OIDC fields → 403 `forbidden_role`.
|
||||||
|
- 3b: `update_settings_admin_allowed` — POST as Admin with OIDC fields → 200 + audit row written.
|
||||||
|
- 3c: `update_settings_smtp_operator_denied` — POST as Operator with SMTP fields → 403 `forbidden_role`.
|
||||||
|
- 3d: `update_settings_smtp_admin_allowed` — POST as Admin with SMTP fields → 200 + audit row written.
|
||||||
|
- 3e: `update_ip_whitelist_operator_denied` — POST as Operator → 403 `forbidden_role`.
|
||||||
|
- 3f: `update_ip_whitelist_admin_allowed` — POST as Admin → 200 + audit row written + in-memory `AuthConfig.ip_whitelist` updated.
|
||||||
|
- 3g: `discover_oidc_operator_denied` / `discover_oidc_admin_allowed` — 2 tests.
|
||||||
|
- 3h: `test_oidc_operator_denied` / `test_oidc_admin_allowed` — 2 tests.
|
||||||
|
- 3i: Run `cargo test -p pm-web --bins --tests` and confirm all green.
|
||||||
|
|
||||||
|
### Phase 4: SPA error message + 1 test
|
||||||
|
- 4a: Update `frontend/src/pages/SettingsPage.tsx` to detect `error.code === 'forbidden_role'` and show friendly message: "Only Admins can modify authentication configuration. Contact an Admin to make this change."
|
||||||
|
- 4b: Create `frontend/src/pages/__tests__/SettingsPage.test.tsx` with 1 test: `settings_page_forbidden_role_shows_friendly_message`.
|
||||||
|
- 4c: Run `npm test` in `frontend/` and confirm green.
|
||||||
|
|
||||||
|
### Phase 5: Documentation
|
||||||
|
- 5a: Update `docs/security-review.md` §2.3 (Authorization / RBAC) with 2 new rows.
|
||||||
|
- 5b: Annotate the 4 affected endpoints in `docs/REST_API.md` with "🔒 Admin only".
|
||||||
|
- 5c: Add a project-specific lesson in `tasks/lessons.md` about the role model (Admin = Manager-wide, Operator = per-host, Reporter = read-only).
|
||||||
|
|
||||||
|
### Phase 6: Review & commit
|
||||||
|
- 6a: Self-review against the 9 acceptance criteria in the spec.
|
||||||
|
- 6b: Manual pre-push checks (cargo fmt, cargo clippy, eslint, cargo test, npm test) — run all 6 from the recent lessons-learned entry.
|
||||||
|
- 6c: Commit on `fix/5-operator-can-modify-auth-config` with conventional format.
|
||||||
|
- 6d: Push to `github/fix/5-operator-can-modify-auth-config` via `github-echo` SSH alias.
|
||||||
|
- 6e: Open PR against `master` and comment on issue #5.
|
||||||
|
- 6f: Capture lessons in `tasks/lessons.md` (project-specific) and `git-workflow/references/lessons-learned.md` (skill-level).
|
||||||
|
|||||||
Reference in New Issue
Block a user