Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions crates/temps-audit/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::sync::Arc;
use temps_core::plugin::{
PluginContext, PluginError, PluginRoutes, ServiceRegistrationContext, TempsPlugin,
};
use temps_core::AuditLogger;
use temps_core::{AuditLogger, AuditLoggerSlot};
use utoipa::OpenApi;

use crate::{handlers, AuditService};
Expand Down Expand Up @@ -42,7 +42,10 @@ impl TempsPlugin for AuditPlugin {
// Create AuditService
let audit_service = Arc::new(AuditService::new(db.clone(), ip_address_service.clone()));
context.register_service(audit_service.clone());
let audit_trait: Arc<dyn AuditLogger> = audit_service.clone();
let initial_logger: Arc<dyn AuditLogger> = audit_service.clone();
let audit_slot = Arc::new(AuditLoggerSlot::new(initial_logger));
context.register_service(audit_slot.clone());
let audit_trait: Arc<dyn AuditLogger> = audit_slot;
context.register_service(audit_trait);

tracing::debug!("Audit plugin services registered successfully");
Expand Down
37 changes: 37 additions & 0 deletions crates/temps-auth/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ pub enum Permission {
SettingsRead,
SettingsWrite,

// DNS provider and unattended automation permissions
DnsProvidersRead,
DnsProvidersWrite,
DnsAutomationWrite,

// Files permissions
FilesRead,
FilesWrite,
Expand Down Expand Up @@ -323,6 +328,9 @@ impl fmt::Display for Permission {
Permission::WebSocketProxyConnect => "websocket_proxy:connect",
Permission::SettingsRead => "settings:read",
Permission::SettingsWrite => "settings:write",
Permission::DnsProvidersRead => "dns_providers:read",
Permission::DnsProvidersWrite => "dns_providers:write",
Permission::DnsAutomationWrite => "dns_automation:write",
Permission::ErrorTrackingRead => "error_tracking:read",
Permission::ErrorTrackingWrite => "error_tracking:write",
Permission::ErrorTrackingCreate => "error_tracking:create",
Expand Down Expand Up @@ -437,6 +445,9 @@ impl Permission {
"external_services:create" => Some(Permission::ExternalServicesCreate),
"settings:read" => Some(Permission::SettingsRead),
"settings:write" => Some(Permission::SettingsWrite),
"dns_providers:read" => Some(Permission::DnsProvidersRead),
"dns_providers:write" => Some(Permission::DnsProvidersWrite),
"dns_automation:write" => Some(Permission::DnsAutomationWrite),
"files:read" => Some(Permission::FilesRead),
"files:write" => Some(Permission::FilesWrite),
"files:delete" => Some(Permission::FilesDelete),
Expand Down Expand Up @@ -583,6 +594,9 @@ impl Permission {
Permission::ExternalServicesCreate,
Permission::SettingsRead,
Permission::SettingsWrite,
Permission::DnsProvidersRead,
Permission::DnsProvidersWrite,
Permission::DnsAutomationWrite,
Permission::FilesRead,
Permission::FilesWrite,
Permission::FilesDelete,
Expand Down Expand Up @@ -807,6 +821,9 @@ impl Role {
Permission::SessionMetricsRead,
Permission::SettingsRead,
Permission::SettingsWrite,
Permission::DnsProvidersRead,
Permission::DnsProvidersWrite,
Permission::DnsAutomationWrite,
Permission::SecretsRead,
Permission::SpeedInsightsRead,
Permission::SystemAdmin,
Expand Down Expand Up @@ -946,6 +963,9 @@ impl Role {
Permission::SessionMetricsRead,
Permission::SettingsRead,
Permission::SettingsWrite,
Permission::DnsProvidersRead,
Permission::DnsProvidersWrite,
Permission::DnsAutomationWrite,
Permission::SpeedInsightsRead,
Permission::SystemAdmin,
Permission::SystemRead,
Expand Down Expand Up @@ -1306,6 +1326,23 @@ mod tests {
assert!(admin_permissions.contains(&Permission::EmailsSend));
}

#[test]
fn dns_governance_permissions_round_trip_and_stay_admin_only() {
for (permission, serialized) in [
(Permission::DnsProvidersRead, "dns_providers:read"),
(Permission::DnsProvidersWrite, "dns_providers:write"),
(Permission::DnsAutomationWrite, "dns_automation:write"),
] {
assert_eq!(permission.to_string(), serialized);
assert_eq!(Permission::from_str(serialized), Some(permission));
assert!(Permission::all().contains(&permission));
assert!(Role::Admin.has_permission(&permission));
assert!(Role::PlatformAdmin.has_permission(&permission));
assert!(!Role::User.has_permission(&permission));
assert!(!Role::Reader.has_permission(&permission));
}
}

#[test]
fn test_user_has_email_permissions() {
let user_permissions = Role::User.permissions();
Expand Down
14 changes: 10 additions & 4 deletions crates/temps-cli/src/commands/serve/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,11 @@ pub async fn start_console_api(params: ConsoleApiParams) -> anyhow::Result<()> {
service_context.register_service(encryption_service.clone());
service_context.register_service(cookie_crypto.clone());
service_context.register_service(docker.clone());
// Background DNS mutation is fail-closed until an optional policy plugin
// claims this slot. DomainsPlugin captures the slot before later plugins
// register, so the indirection must exist before plugin initialization.
let dns_automation_gate_slot = Arc::new(temps_core::DnsAutomationGateSlot::new());
service_context.register_service(dns_automation_gate_slot);
// Pre-registered before any plugin runs so ProxyPlugin uses this exact
// slot instance instead of creating its own — see the field doc on
// `ConsoleApiParams::retention_resolver_slot`.
Expand Down Expand Up @@ -1898,10 +1903,11 @@ pub async fn start_console_api(params: ConsoleApiParams) -> anyhow::Result<()> {
// (depends only on ServerConfig for the data dir). Registered early so
// every later plugin can require the Arc<dyn TelemetryReporter>.
debug!("Registering TelemetryPlugin");
let telemetry_plugin = Box::new(TelemetryPlugin::new(
config.clone(),
env!("CARGO_PKG_VERSION"),
));
// TEMPS_VERSION (git-describe, set by build.rs) is used instead of
// CARGO_PKG_VERSION so nightly/beta builds report a version telemetry
// can actually distinguish from a tagged release -- CARGO_PKG_VERSION
// is the static Cargo.toml version and is identical across all of them.
let telemetry_plugin = Box::new(TelemetryPlugin::new(config.clone(), env!("TEMPS_VERSION")));
plugin_manager.register_plugin(telemetry_plugin);

// 2. QueuePlugin - registers the pre-created job queue into the service context
Expand Down
135 changes: 135 additions & 0 deletions crates/temps-core/src/audit.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use anyhow::Result;
use serde::Serialize;
use std::sync::{Arc, RwLock};
use thiserror::Error;

/// Context information common to all audit events
#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -45,3 +47,136 @@ pub trait AuditLogger: Send + Sync {
/// Creates an audit log entry for the given operation
async fn create_audit_log(&self, operation: &dyn AuditOperation) -> Result<()>;
}

/// Stable indirection for audit consumers constructed before optional
/// decorators register. Replacing the target updates every previously-captured
/// `Arc<dyn AuditLogger>`, so decorators registered later cannot be bypassed.
pub struct AuditLoggerSlot {
target: RwLock<Arc<dyn AuditLogger>>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum AuditLoggerSlotError {
#[error("audit logger slot read lock is poisoned")]
ReadLockPoisoned,
#[error("audit logger slot write lock is poisoned")]
WriteLockPoisoned,
}

impl AuditLoggerSlot {
pub fn new(target: Arc<dyn AuditLogger>) -> Self {
Self {
target: RwLock::new(target),
}
}

pub fn current(&self) -> std::result::Result<Arc<dyn AuditLogger>, AuditLoggerSlotError> {
self.target
.read()
.map(|target| target.clone())
.map_err(|_| AuditLoggerSlotError::ReadLockPoisoned)
}

pub fn replace(
&self,
target: Arc<dyn AuditLogger>,
) -> std::result::Result<(), AuditLoggerSlotError> {
let mut current = self
.target
.write()
.map_err(|_| AuditLoggerSlotError::WriteLockPoisoned)?;
*current = target;
Ok(())
}
}

#[async_trait::async_trait]
impl AuditLogger for AuditLoggerSlot {
async fn create_audit_log(&self, operation: &dyn AuditOperation) -> Result<()> {
let target = self.current().map_err(anyhow::Error::new)?;
target.create_audit_log(operation).await
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};

struct TestOperation;

impl AuditOperation for TestOperation {
fn operation_type(&self) -> String {
"TEST".to_string()
}
fn user_id(&self) -> Option<i32> {
None
}
fn ip_address(&self) -> Option<String> {
None
}
fn user_agent(&self) -> &str {
"test"
}
fn serialize(&self) -> Result<String> {
Ok("{}".to_string())
}
}

struct CountingLogger(Arc<AtomicUsize>);

#[async_trait::async_trait]
impl AuditLogger for CountingLogger {
async fn create_audit_log(&self, _operation: &dyn AuditOperation) -> Result<()> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}

#[tokio::test]
async fn captured_trait_object_routes_to_replacement() {
let original_count = Arc::new(AtomicUsize::new(0));
let replacement_count = Arc::new(AtomicUsize::new(0));
let original: Arc<dyn AuditLogger> = Arc::new(CountingLogger(original_count.clone()));
let slot = Arc::new(AuditLoggerSlot::new(original));
let captured: Arc<dyn AuditLogger> = slot.clone();

captured.create_audit_log(&TestOperation).await.unwrap();
slot.replace(Arc::new(CountingLogger(replacement_count.clone())))
.unwrap();
captured.create_audit_log(&TestOperation).await.unwrap();

assert_eq!(original_count.load(Ordering::SeqCst), 1);
assert_eq!(replacement_count.load(Ordering::SeqCst), 1);
}

#[test]
fn poisoned_slot_returns_typed_errors() {
fn poisoned_slot() -> Arc<AuditLoggerSlot> {
let logger: Arc<dyn AuditLogger> =
Arc::new(CountingLogger(Arc::new(AtomicUsize::new(0))));
let slot = Arc::new(AuditLoggerSlot::new(logger));
let worker_slot = slot.clone();
let _ = std::thread::spawn(move || {
let _write_guard = worker_slot.target.write().unwrap();
panic!("poison audit logger slot for test");
})
.join();
slot
}

let read_slot = poisoned_slot();
assert!(matches!(
read_slot.current(),
Err(AuditLoggerSlotError::ReadLockPoisoned)
));

let write_slot = poisoned_slot();
let replacement: Arc<dyn AuditLogger> =
Arc::new(CountingLogger(Arc::new(AtomicUsize::new(0))));
assert!(matches!(
write_slot.replace(replacement),
Err(AuditLoggerSlotError::WriteLockPoisoned)
));
}
}
Loading
Loading