From 3878bd49520e777826f4b9d695d9ff948a15e0e2 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 14 May 2026 02:23:18 +0000 Subject: [PATCH] feat: add reporter role for SSO auto-provisioning --- crates/pm-auth/src/rbac.rs | 8 ++ crates/pm-core/src/models.rs | 2 + crates/pm-web/src/routes/ca.rs | 16 +-- crates/pm-web/src/routes/discovery.rs | 8 +- crates/pm-web/src/routes/groups.rs | 20 ++-- crates/pm-web/src/routes/health_checks.rs | 104 ++++-------------- crates/pm-web/src/routes/hosts.rs | 26 +++-- crates/pm-web/src/routes/jobs.rs | 15 ++- .../pm-web/src/routes/maintenance_windows.rs | 21 ++++ crates/pm-web/src/routes/settings.rs | 22 ++-- crates/pm-web/src/routes/sso.rs | 8 +- frontend/src/components/AppLayout.tsx | 17 ++- frontend/src/pages/CertificatesPage.tsx | 6 +- frontend/src/pages/GroupsPage.tsx | 11 +- frontend/src/pages/HostDetailPage.tsx | 30 ++--- frontend/src/pages/HostsPage.tsx | 11 +- frontend/src/pages/JobsPage.tsx | 12 +- frontend/src/pages/MaintenanceWindowsPage.tsx | 18 +-- frontend/src/pages/SettingsPage.tsx | 7 +- frontend/src/types/index.ts | 2 +- migrations/015_reporter_role.sql | 14 +++ 21 files changed, 204 insertions(+), 174 deletions(-) create mode 100644 migrations/015_reporter_role.sql diff --git a/crates/pm-auth/src/rbac.rs b/crates/pm-auth/src/rbac.rs index 1ae1ad6..0a528db 100644 --- a/crates/pm-auth/src/rbac.rs +++ b/crates/pm-auth/src/rbac.rs @@ -36,6 +36,7 @@ pub struct AuthUser { pub enum UserRole { Admin, Operator, + Reporter, } impl UserRole { @@ -43,6 +44,7 @@ impl UserRole { match s { "admin" => Some(Self::Admin), "operator" => Some(Self::Operator), + "reporter" => Some(Self::Reporter), _ => None, } } @@ -51,6 +53,7 @@ impl UserRole { match self { Self::Admin => "admin", Self::Operator => "operator", + Self::Reporter => "reporter", } } @@ -58,6 +61,11 @@ impl UserRole { pub fn is_admin(&self) -> bool { matches!(self, Self::Admin) } + + /// Admin and Operator can write; Reporter is read-only. + pub fn can_write(&self) -> bool { + matches!(self, Self::Admin | Self::Operator) + } } /// Shared auth configuration injected via Axum state. diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index bd07c8a..af5437e 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -38,6 +38,7 @@ impl std::fmt::Display for HostHealthStatus { pub enum UserRole { Admin, Operator, + Reporter, } impl std::fmt::Display for UserRole { @@ -45,6 +46,7 @@ impl std::fmt::Display for UserRole { match self { Self::Admin => write!(f, "admin"), Self::Operator => write!(f, "operator"), + Self::Reporter => write!(f, "reporter"), } } } diff --git a/crates/pm-web/src/routes/ca.rs b/crates/pm-web/src/routes/ca.rs index 4a67ce5..5e832dc 100644 --- a/crates/pm-web/src/routes/ca.rs +++ b/crates/pm-web/src/routes/ca.rs @@ -104,11 +104,11 @@ fn pem_response(pem: String, filename: &str) -> Result, (StatusCo // ── Helper: admin-only guard ────────────────────────────────────────────────── -fn require_admin(user: &AuthUser) -> Result<(), (StatusCode, Json)> { - if !user.role.is_admin() { +fn require_write_access(user: &AuthUser) -> Result<(), (StatusCode, Json)> { + if !user.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } Ok(()) @@ -240,7 +240,7 @@ async fn download_client_cert( auth: AuthUser, Path(host_id): Path, ) -> Result, (StatusCode, Json)> { - require_admin(&auth)?; + require_write_access(&auth)?; let cert_pem: Option = sqlx::query_scalar( r#"SELECT cert_pem @@ -297,7 +297,7 @@ async fn issue_client_cert( Path(host_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - require_admin(&auth)?; + require_write_access(&auth)?; // Look up the host's IP address from the database. let ip_address: String = sqlx::query_scalar("SELECT host(ip_address) FROM hosts WHERE id = $1") @@ -353,7 +353,7 @@ async fn renew_cert( auth: AuthUser, Path(cert_id): Path, ) -> Result, (StatusCode, Json)> { - require_admin(&auth)?; + require_write_access(&auth)?; let issued = state.ca.renew_cert(cert_id, &state.db).await.map_err(|e| { let msg = e.to_string(); @@ -398,7 +398,7 @@ async fn reissue_host_cert( auth: AuthUser, Path(host_id): Path, ) -> Result, (StatusCode, Json)> { - require_admin(&auth)?; + require_write_access(&auth)?; // Look up the host's FQDN and IP address for the new certificate CN and SANs. let row = sqlx::query("SELECT fqdn, host(ip_address) AS ip_address FROM hosts WHERE id = $1") @@ -475,7 +475,7 @@ async fn revoke_cert( auth: AuthUser, Path(cert_id): Path, ) -> Result, (StatusCode, Json)> { - require_admin(&auth)?; + require_write_access(&auth)?; state .ca diff --git a/crates/pm-web/src/routes/discovery.rs b/crates/pm-web/src/routes/discovery.rs index d6ab3dd..4d940e4 100644 --- a/crates/pm-web/src/routes/discovery.rs +++ b/crates/pm-web/src/routes/discovery.rs @@ -45,10 +45,10 @@ async fn start_cidr_scan( auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -221,10 +221,10 @@ async fn register_discovered_host( Path(id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } diff --git a/crates/pm-web/src/routes/groups.rs b/crates/pm-web/src/routes/groups.rs index b2ead1c..fe0edf9 100644 --- a/crates/pm-web/src/routes/groups.rs +++ b/crates/pm-web/src/routes/groups.rs @@ -62,10 +62,10 @@ async fn create_group( auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -154,10 +154,10 @@ async fn update_group( Path(id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -187,10 +187,10 @@ async fn delete_group( auth: AuthUser, Path(id): Path, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -234,10 +234,10 @@ async fn add_user_to_group( auth: AuthUser, Path((id, user_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -276,10 +276,10 @@ async fn remove_user_from_group( auth: AuthUser, Path((id, user_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } diff --git a/crates/pm-web/src/routes/health_checks.rs b/crates/pm-web/src/routes/health_checks.rs index fb5d517..2ce42ff 100644 --- a/crates/pm-web/src/routes/health_checks.rs +++ b/crates/pm-web/src/routes/health_checks.rs @@ -102,8 +102,8 @@ async fn list_health_checks( auth: AuthUser, Path(host_id): Path, ) -> Result, (StatusCode, Json)> { - // RBAC check for operators - if !auth.role.is_admin() { + // RBAC: reporters can only see hosts in their groups + if !auth.role.can_write() { let can_access = operator_can_access_host(&state.db, auth.user_id, host_id) .await .map_err(|e| { @@ -208,25 +208,11 @@ async fn create_health_check( Path(host_id): Path, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { - // RBAC check for operators - if !auth.role.is_admin() { - let can_access = operator_can_access_host(&state.db, auth.user_id, host_id) - .await - .map_err(|e| { - tracing::error!(error = %e, "RBAC check failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), - ) - })?; - if !can_access { - return Err(( - StatusCode::FORBIDDEN, - Json( - json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }), - ), - )); - } + if !auth.role.can_write() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), + )); } // Validate check_type @@ -426,8 +412,8 @@ async fn get_health_check( auth: AuthUser, Path((host_id, check_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { - // RBAC check for operators - if !auth.role.is_admin() { + // RBAC: reporters can only see hosts in their groups + if !auth.role.can_write() { let can_access = operator_can_access_host(&state.db, auth.user_id, host_id) .await .map_err(|e| { @@ -506,25 +492,11 @@ async fn update_health_check( Path((host_id, check_id)): Path<(Uuid, Uuid)>, Json(req): Json, ) -> Result, (StatusCode, Json)> { - // RBAC check for operators - if !auth.role.is_admin() { - let can_access = operator_can_access_host(&state.db, auth.user_id, host_id) - .await - .map_err(|e| { - tracing::error!(error = %e, "RBAC check failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), - ) - })?; - if !can_access { - return Err(( - StatusCode::FORBIDDEN, - Json( - json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }), - ), - )); - } + if !auth.role.can_write() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), + )); } // Verify check exists and belongs to host @@ -746,25 +718,11 @@ async fn delete_health_check( auth: AuthUser, Path((host_id, check_id)): Path<(Uuid, Uuid)>, ) -> Result)> { - // RBAC check for operators - if !auth.role.is_admin() { - let can_access = operator_can_access_host(&state.db, auth.user_id, host_id) - .await - .map_err(|e| { - tracing::error!(error = %e, "RBAC check failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), - ) - })?; - if !can_access { - return Err(( - StatusCode::FORBIDDEN, - Json( - json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }), - ), - )); - } + if !auth.role.can_write() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), + )); } let deleted = sqlx::query("DELETE FROM host_health_checks WHERE id = $1 AND host_id = $2") @@ -811,25 +769,11 @@ async fn test_health_check( auth: AuthUser, Path((host_id, check_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { - // RBAC check for operators - if !auth.role.is_admin() { - let can_access = operator_can_access_host(&state.db, auth.user_id, host_id) - .await - .map_err(|e| { - tracing::error!(error = %e, "RBAC check failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), - ) - })?; - if !can_access { - return Err(( - StatusCode::FORBIDDEN, - Json( - json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }), - ), - )); - } + if !auth.role.can_write() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), + )); } // Get the health check diff --git a/crates/pm-web/src/routes/hosts.rs b/crates/pm-web/src/routes/hosts.rs index 226c090..d723010 100644 --- a/crates/pm-web/src/routes/hosts.rs +++ b/crates/pm-web/src/routes/hosts.rs @@ -7,7 +7,7 @@ //! GET /api/v1/hosts/{id}/groups — list groups for host //! POST /api/v1/hosts/{id}/groups — assign host to group //! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group -//! POST /api/v1/hosts/{id}/refresh — queue on-demand refresh (operator+) +//! POST /api/v1/hosts/{id}/refresh — queue on-demand refresh (write access) use axum::{ extract::{Path, Query, State}, @@ -214,10 +214,10 @@ async fn register_host( Json(req): Json, ) -> Result, (StatusCode, Json)> { // Admin only - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -348,10 +348,10 @@ async fn remove_host( auth: AuthUser, Path(id): Path, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -451,10 +451,10 @@ async fn add_host_to_group( Path(id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -496,10 +496,10 @@ async fn remove_host_from_group( auth: AuthUser, Path((id, group_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } @@ -562,9 +562,15 @@ async fn resolve_fqdn(fqdn: &str) -> Result { /// Requires Operator or Admin role (any authenticated user). async fn refresh_host( State(state): State, - _auth: AuthUser, + auth: AuthUser, Path(id): Path, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { + if !auth.role.can_write() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), + )); + } // Verify the host exists. let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)") .bind(id) diff --git a/crates/pm-web/src/routes/jobs.rs b/crates/pm-web/src/routes/jobs.rs index 674a1a1..c1af3a8 100644 --- a/crates/pm-web/src/routes/jobs.rs +++ b/crates/pm-web/src/routes/jobs.rs @@ -116,6 +116,13 @@ async fn create_job( auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { + if !auth.role.can_write() { + return Err(err( + StatusCode::FORBIDDEN, + "forbidden", + "Write access required", + )); + } if req.host_ids.is_empty() { return Err(err( StatusCode::BAD_REQUEST, @@ -430,13 +437,13 @@ async fn cancel_job( row.ok_or_else(|| err(StatusCode::NOT_FOUND, "not_found", "Job not found"))?; // Only admin or the job creator may cancel. - if !auth.role.is_admin() { + if !auth.role.can_write() { let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id); if !is_creator { return Err(err( StatusCode::FORBIDDEN, "forbidden", - "Only admin or the job creator may cancel this job", + "Write access required", )); } } @@ -535,11 +542,11 @@ async fn rollback_job( Path(id): Path, ) -> Result, (StatusCode, Json)> { // Admin-only operation. - if !auth.role.is_admin() { + if !auth.role.can_write() { return Err(err( StatusCode::FORBIDDEN, "forbidden", - "Admin role required to create rollback jobs", + "Write access required", )); } diff --git a/crates/pm-web/src/routes/maintenance_windows.rs b/crates/pm-web/src/routes/maintenance_windows.rs index b0ec544..7f59efc 100644 --- a/crates/pm-web/src/routes/maintenance_windows.rs +++ b/crates/pm-web/src/routes/maintenance_windows.rs @@ -103,6 +103,13 @@ async fn create_window( Path(host_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { + if !auth.role.can_write() { + return Err(err( + StatusCode::FORBIDDEN, + "forbidden", + "Write access required", + )); + } // Validate: weekly requires recurrence_day 0-6 if req.recurrence == pm_core::models::WindowRecurrence::Weekly { match req.recurrence_day { @@ -218,6 +225,13 @@ async fn update_window( Path((host_id, win_id)): Path<(Uuid, Uuid)>, Json(req): Json, ) -> Result, (StatusCode, Json)> { + if !auth.role.can_write() { + return Err(err( + StatusCode::FORBIDDEN, + "forbidden", + "Write access required", + )); + } // Fetch existing record (verify ownership and existence). let existing: Option = sqlx::query_as( r#" @@ -349,6 +363,13 @@ async fn delete_window( auth: AuthUser, Path((host_id, win_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { + if !auth.role.can_write() { + return Err(err( + StatusCode::FORBIDDEN, + "forbidden", + "Write access required", + )); + } let result = sqlx::query("DELETE FROM maintenance_windows WHERE id = $1 AND host_id = $2") .bind(win_id) .bind(host_id) diff --git a/crates/pm-web/src/routes/settings.rs b/crates/pm-web/src/routes/settings.rs index 09b3aa7..0559dee 100644 --- a/crates/pm-web/src/routes/settings.rs +++ b/crates/pm-web/src/routes/settings.rs @@ -169,11 +169,11 @@ pub fn router() -> Router { const MASKED: &str = "********"; -fn admin_only(auth: &AuthUser) -> Result<(), (StatusCode, Json)> { - if !auth.role.is_admin() { +fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json)> { + if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, - Json(json!({ "error": { "code": "forbidden", "message": "Admin access required" } })), + Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } Ok(()) @@ -311,7 +311,7 @@ async fn get_settings( State(state): State, auth: AuthUser, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; let cfg = load_system_config(&state.db).await?; // Inject read-only config values from TOML file (not stored in DB) let mut cfg = cfg; @@ -332,7 +332,7 @@ async fn update_settings( auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; // Update OIDC config if let Some(oidc) = req.oidc { @@ -562,7 +562,7 @@ async fn discover_oidc( auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; if req.discovery_url.is_empty() { return Err(( @@ -619,7 +619,7 @@ async fn test_oidc( State(state): State, auth: AuthUser, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; let row: Option<(bool, String, String)> = sqlx::query_as( "SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1", @@ -715,7 +715,7 @@ async fn test_smtp( State(state): State, auth: AuthUser, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; let cfg = load_system_config(&state.db).await?; @@ -870,7 +870,7 @@ async fn get_ip_whitelist( State(state): State, auth: AuthUser, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; let value: Option = sqlx::query_scalar( "SELECT value FROM system_config WHERE key = 'ip_whitelist'", @@ -898,7 +898,7 @@ async fn update_ip_whitelist( auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; // Validate each entry for entry in &req.entries { @@ -943,7 +943,7 @@ async fn audit_integrity( State(state): State, auth: AuthUser, ) -> Result, (StatusCode, Json)> { - admin_only(&auth)?; + write_access_required(&auth)?; let result = verify_integrity(&state.db).await; diff --git a/crates/pm-web/src/routes/sso.rs b/crates/pm-web/src/routes/sso.rs index 2442205..fc2defb 100644 --- a/crates/pm-web/src/routes/sso.rs +++ b/crates/pm-web/src/routes/sso.rs @@ -504,9 +504,9 @@ async fn sso_callback( None => { // No existing user - create new one let id: Uuid = match sqlx::query_scalar( - r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub) - VALUES ($1, $2, $3, 'operator'::user_role, $4::auth_provider, $5, $6) - RETURNING id"#, + r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub) + VALUES ($1, $2, $3, 'reporter'::user_role, $4::auth_provider, $5, $6) + RETURNING id"#, ) .bind(&preferred_username) .bind(&name) @@ -541,7 +541,7 @@ async fn sso_callback( id, username: preferred_username, display_name: name, - role: "operator".to_string(), + role: "reporter".to_string(), is_active: true, mfa_enabled: false, } diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 2143489..53c2fe5 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -29,6 +29,7 @@ interface NavItem { path: string icon: React.ReactElement adminOnly?: boolean + writeOnly?: boolean } const navGroups: { heading: string; items: NavItem[] }[] = [ @@ -43,23 +44,23 @@ const navGroups: { heading: string; items: NavItem[] }[] = [ items: [ { label: 'Hosts', path: '/hosts', icon: }, { label: 'Groups', path: '/groups', icon: }, - { label: 'Deploy', path: '/deployment', icon: }, + { label: 'Deploy', path: '/deployment', icon: , writeOnly: true }, ], }, { heading: 'Operations', items: [ { label: 'Jobs', path: '/jobs', icon: }, - { label: 'Maintenance', path: '/maintenance', icon: }, + { label: 'Maintenance', path: '/maintenance', icon: , writeOnly: true }, ], }, { heading: 'Administration', items: [ { label: 'Users', path: '/users', icon: , adminOnly: true }, - { label: 'Certificates', path: '/certificates', icon: , adminOnly: true }, + { label: 'Certificates', path: '/certificates', icon: }, { label: 'Reports', path: '/reports', icon: }, - { label: 'Settings', path: '/settings', icon: , adminOnly: true }, + { label: 'Settings', path: '/settings', icon: }, ], }, ] @@ -72,7 +73,7 @@ export default function AppLayout() { const [anchorEl, setAnchorEl] = useState(null) const isAdmin = user?.role === 'admin' - + const canWrite = user?.role === 'admin' || user?.role === 'operator' const handleDrawerToggle = () => setMobileOpen(!mobileOpen) const handleMenuOpen = (e: React.MouseEvent) => setAnchorEl(e.currentTarget) const handleMenuClose = () => setAnchorEl(null) @@ -97,7 +98,11 @@ export default function AppLayout() { {navGroups.map((group) => { - const visibleItems = group.items.filter((item) => !item.adminOnly || isAdmin) + const visibleItems = group.items.filter((item) => { + if (item.adminOnly && !isAdmin) return false + if (item.writeOnly && !canWrite) return false + return true + }) if (visibleItems.length === 0) return null return ( diff --git a/frontend/src/pages/CertificatesPage.tsx b/frontend/src/pages/CertificatesPage.tsx index 0fb5632..a3f2640 100644 --- a/frontend/src/pages/CertificatesPage.tsx +++ b/frontend/src/pages/CertificatesPage.tsx @@ -286,7 +286,7 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro export default function CertificatesPage() { const user = useAuthStore((s) => s.user) - const isAdmin = user?.role === 'admin' + const canWrite = user?.role === 'admin' || user?.role === 'operator' const [certs, setCerts] = useState([]) const [loading, setLoading] = useState(true) @@ -378,7 +378,7 @@ export default function CertificatesPage() { Certificate Management - {isAdmin && ( + {canWrite && ( + {canWrite && } {loading ? : ( - NameDescriptionCreatedActions + NameDescriptionCreated{canWrite && Actions} {groups.map(g => ( @@ -53,9 +56,9 @@ export default function GroupsPage() { {g.name} {g.description || '—'} {new Date(g.created_at).toLocaleDateString()} - + {canWrite && handleDelete(g.id)}> - + } ))} diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 770acf5..3c82dab 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -48,6 +48,7 @@ import { ContentCopy as CopyIcon, } from '@mui/icons-material' import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client' +import { useAuthStore } from '../store/authStore' import type { CreateHostRequest, IssuedCert, @@ -552,6 +553,8 @@ function CreateHostForm() { export default function HostDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() + const user = useAuthStore(state => state.user) + const canWrite = user?.role === 'admin' || user?.role === 'operator' const [host, setHost] = useState | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -896,7 +899,7 @@ export default function HostDetailPage() { {String(host?.fqdn ?? '')} - {!certExists && ( + {canWrite && !certExists && ( + } @@ -971,7 +974,7 @@ export default function HostDetailPage() { Schedule Recurrence Status - Actions + {canWrite && Actions} @@ -991,7 +994,7 @@ export default function HostDetailPage() { size="small" /> - + {canWrite && handleEditClick(w)}> @@ -1005,7 +1008,7 @@ export default function HostDetailPage() { - + } ))} @@ -1020,7 +1023,7 @@ export default function HostDetailPage() { Health Checks - + } @@ -1054,7 +1057,7 @@ export default function HostDetailPage() { Detail Latency Last Checked - Actions + {canWrite && Actions} @@ -1092,7 +1095,8 @@ export default function HostDetailPage() { handleToggleEnabled(check)} + onChange={canWrite ? () => handleToggleEnabled(check) : undefined} + disabled={!canWrite} /> @@ -1108,7 +1112,7 @@ export default function HostDetailPage() { ? new Date(check.last_result.checked_at).toLocaleString() : '—'} - + {canWrite && - + } ))} diff --git a/frontend/src/pages/HostsPage.tsx b/frontend/src/pages/HostsPage.tsx index 3b0c8de..e968448 100644 --- a/frontend/src/pages/HostsPage.tsx +++ b/frontend/src/pages/HostsPage.tsx @@ -7,6 +7,7 @@ import { import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material' import { useNavigate } from 'react-router-dom' import { apiClient, hostsApi } from '../api/client' +import { useAuthStore } from '../store/authStore' import type { Host, HostHealthStatus } from '../types' const statusColor = (s: HostHealthStatus) => @@ -14,6 +15,8 @@ const statusColor = (s: HostHealthStatus) => export default function HostsPage() { const navigate = useNavigate() + const user = useAuthStore(state => state.user) + const canWrite = user?.role === 'admin' || user?.role === 'operator' const [hosts, setHosts] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) @@ -55,7 +58,7 @@ export default function HostsPage() { setSearch(e.target.value)} sx={{ mr: 2 }} /> - + {canWrite && } {loading ? : ( @@ -69,7 +72,7 @@ export default function HostsPage() { Health Checks Agent - Actions + {canWrite && Actions} @@ -93,7 +96,7 @@ export default function HostsPage() { )} {h.agent_version ?? '—'} - e.stopPropagation()}> + {canWrite && e.stopPropagation()}> - + } ))} diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index 775bc4c..74570cc 100644 --- a/frontend/src/pages/JobsPage.tsx +++ b/frontend/src/pages/JobsPage.tsx @@ -33,6 +33,7 @@ import { WifiOff as WifiOffIcon, } from '@mui/icons-material' import { jobsApi } from '../api/client' +import { useAuthStore } from '../store/authStore' import { useJobWebSocket } from '../hooks/useJobWebSocket' import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost, JobWsEvent } from '../types' @@ -153,6 +154,7 @@ interface JobRowProps { detail: PatchJob | null detailLoading: boolean detailError: string | null + canWrite: boolean } function JobRow({ @@ -166,6 +168,7 @@ function JobRow({ detail, detailLoading, detailError, + canWrite, }: JobRowProps) { const canCancel = job.status === 'queued' || job.status === 'pending' const canRollback = job.status === 'succeeded' @@ -224,7 +227,7 @@ function JobRow({ e.stopPropagation()}> - + {canWrite ? {canCancel && ( @@ -261,7 +264,7 @@ function JobRow({ )} - + : null} @@ -289,6 +292,8 @@ function JobRow({ // ── JobsPage ────────────────────────────────────────────────────────────────── export default function JobsPage() { + const user = useAuthStore(state => state.user) + const canWrite = user?.role === 'admin' || user?.role === 'operator' const [jobs, setJobs] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -512,7 +517,7 @@ export default function JobsPage() { Failed Schedule Notes - Actions + {canWrite && Actions} @@ -544,6 +549,7 @@ export default function JobsPage() { detail={details[job.id] ?? null} detailLoading={detailLoading[job.id] ?? false} detailError={detailError[job.id] ?? null} + canWrite={canWrite} /> )) )} diff --git a/frontend/src/pages/MaintenanceWindowsPage.tsx b/frontend/src/pages/MaintenanceWindowsPage.tsx index edc69e6..b502c32 100644 --- a/frontend/src/pages/MaintenanceWindowsPage.tsx +++ b/frontend/src/pages/MaintenanceWindowsPage.tsx @@ -37,6 +37,7 @@ import { Schedule as ScheduleIcon, } from '@mui/icons-material' import { maintenanceWindowsApi, hostsApi } from '../api/client' +import { useAuthStore } from '../store/authStore' import type { Host, MaintenanceWindow, WindowRecurrence } from '../types' // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -311,9 +312,10 @@ interface HostWindowsTableProps { onEdit: (w: MaintenanceWindow) => void onDelete: (w: MaintenanceWindow) => void onAdd: (hostId: string) => void + canWrite: boolean } -function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindowsTableProps) { +function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd, canWrite }: HostWindowsTableProps) { return ( - + } {windows.length === 0 ? ( @@ -362,7 +364,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow Status Auto-Apply Created - Actions + {canWrite && Actions} @@ -394,7 +396,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow /> {fmtDate(w.created_at)} - + {canWrite && onEdit(w)}> @@ -405,7 +407,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow - + } ))} @@ -418,6 +420,8 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow // ── Main page ────────────────────────────────────────────────────────────────── export default function MaintenanceWindowsPage() { + const user = useAuthStore(state => state.user) + const canWrite = user?.role === 'admin' || user?.role === 'operator' const [hosts, setHosts] = useState([]) const [windowsByHost, setWindowsByHost] = useState>({}) const [loading, setLoading] = useState(true) @@ -593,9 +597,9 @@ export default function MaintenanceWindowsPage() { onEdit={handleEditClick} onDelete={handleDeleteClick} onAdd={handleAddClick} + canWrite={canWrite} /> ))} - {/* Create dialog */} state.user) + const canWrite = user?.role === 'admin' || user?.role === 'operator' const [oidc, setOidc] = useState({ enabled: false, provider_type: 'azure', display_name: 'Azure AD', discovery_url: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid profile email', @@ -202,9 +205,9 @@ export default function SettingsPage() { Settings - + } {error && setError(null)}>{error}} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 123fbf0..6d4d603 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,6 +1,6 @@ // Core TypeScript types — expanded per milestone -export type UserRole = 'admin' | 'operator' +export type UserRole = 'admin' | 'operator' | 'reporter' export type AuthProvider = 'local' | 'azure_sso' | 'keycloak' | 'oidc' export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable' export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' diff --git a/migrations/015_reporter_role.sql b/migrations/015_reporter_role.sql new file mode 100644 index 0000000..238776b --- /dev/null +++ b/migrations/015_reporter_role.sql @@ -0,0 +1,14 @@ +-- Migration 015: Add 'reporter' role to user_role enum +-- Reporter is a read-only role for SSO auto-provisioned users. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'user_role' AND e.enumlabel = 'reporter' + ) THEN + ALTER TYPE user_role ADD VALUE 'reporter'; + END IF; +END +$$;