From 287045d240c4fe836ff818627387766ce764e063 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 18 Dec 2024 16:44:29 -0600 Subject: [PATCH] initial audit log endpoints, data model, tests --- Cargo.lock | 1 + common/src/api/external/http_pagination.rs | 52 ++++ common/src/api/external/mod.rs | 1 + nexus/auth/src/authn/mod.rs | 9 + nexus/auth/src/authz/api_resources.rs | 62 ++++- nexus/auth/src/authz/omicron.polar | 19 ++ nexus/auth/src/authz/oso_generic.rs | 1 + nexus/db-model/src/audit_log.rs | 142 ++++++++++ nexus/db-model/src/lib.rs | 2 + nexus/db-model/src/schema.rs | 19 ++ nexus/db-model/src/schema_versions.rs | 3 +- .../db-queries/src/db/datastore/audit_log.rs | 257 ++++++++++++++++++ nexus/db-queries/src/db/datastore/mod.rs | 1 + .../src/policy_test/resource_builder.rs | 1 + nexus/db-queries/src/policy_test/resources.rs | 1 + nexus/db-queries/tests/output/authz-roles.out | 14 + nexus/external-api/Cargo.toml | 1 + nexus/external-api/output/nexus_tags.txt | 4 + nexus/external-api/src/lib.rs | 26 ++ nexus/src/app/audit_log.rs | 67 +++++ nexus/src/app/mod.rs | 1 + nexus/src/external_api/http_entrypoints.rs | 180 ++++++++---- nexus/tests/integration_tests/audit_log.rs | 96 +++++++ nexus/tests/integration_tests/endpoints.rs | 14 + nexus/tests/integration_tests/mod.rs | 1 + nexus/types/src/external_api/params.rs | 9 + nexus/types/src/external_api/views.rs | 41 +++ openapi/nexus.json | 202 ++++++++++++++ schema/crdb/audit-log/up01.sql | 26 ++ schema/crdb/dbinit.sql | 29 +- 30 files changed, 1227 insertions(+), 55 deletions(-) create mode 100644 nexus/db-model/src/audit_log.rs create mode 100644 nexus/db-queries/src/db/datastore/audit_log.rs create mode 100644 nexus/src/app/audit_log.rs create mode 100644 nexus/tests/integration_tests/audit_log.rs create mode 100644 schema/crdb/audit-log/up01.sql diff --git a/Cargo.lock b/Cargo.lock index ec46092b272..e900a79649e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5911,6 +5911,7 @@ name = "nexus-external-api" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "dropshot 0.15.1", "http", "hyper", diff --git a/common/src/api/external/http_pagination.rs b/common/src/api/external/http_pagination.rs index 65237f73c6a..cd4b49498c8 100644 --- a/common/src/api/external/http_pagination.rs +++ b/common/src/api/external/http_pagination.rs @@ -45,6 +45,8 @@ use crate::api::external::Name; use crate::api::external::NameOrId; use crate::api::external::ObjectIdentity; use crate::api::external::PaginationOrder; +use chrono::DateTime; +use chrono::Utc; use dropshot::HttpError; use dropshot::PaginationParams; use dropshot::RequestContext; @@ -409,6 +411,56 @@ impl< } } +/// Query parameters for pagination by timestamp and ID +pub type PaginatedByTimestampAndId = PaginationParams< + ScanByTimestampAndId, + PageSelectorByTimestampAndId, +>; +/// Page selector for pagination by timestamp and ID +pub type PageSelectorByTimestampAndId = + PageSelector, (DateTime, Uuid)>; + +/// Scan parameters for resources that support scanning by (timestamp, id) +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +pub struct ScanByTimestampAndId { + #[serde(default = "default_ts_id_sort_mode")] + sort_by: TimestampAndIdSortMode, + + #[serde(flatten)] + pub selector: Selector, +} +/// Supported set of sort modes for scanning by timestamp and ID +/// +/// Currently, we only support scanning in ascending order. +#[derive(Copy, Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TimestampAndIdSortMode { + /// sort in increasing order of timestamp and ID + Ascending, +} + +fn default_ts_id_sort_mode() -> TimestampAndIdSortMode { + TimestampAndIdSortMode::Ascending +} + +impl< + T: Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize, + > ScanParams for ScanByTimestampAndId +{ + type MarkerValue = (DateTime, Uuid); + fn direction(&self) -> PaginationOrder { + PaginationOrder::Ascending + } + fn from_query( + p: &PaginatedByTimestampAndId, + ) -> Result<&Self, HttpError> { + Ok(match p.page { + WhichPage::First(ref scan_params) => scan_params, + WhichPage::Next(PageSelector { ref scan, .. }) => scan, + }) + } +} + #[cfg(test)] mod test { use super::data_page_params_with_limit; diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 723cf856e7d..c3ed0ae8b60 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -971,6 +971,7 @@ pub enum ResourceType { AddressLot, AddressLotBlock, AllowList, + AuditLogEntry, BackgroundTask, BgpConfig, BgpAnnounceSet, diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index 08b27b97737..c79ea352ef7 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -151,6 +151,15 @@ impl Context { &self.schemes_tried } + /// If the user is authenticated, return the last scheme in the list of + /// schemes tried, which is the one that worked. + pub fn scheme_used(&self) -> Option<&SchemeName> { + match &self.kind { + Kind::Authenticated(..) => self.schemes_tried().last(), + Kind::Unauthenticated => None, + } + } + /// Returns an unauthenticated context for use internally pub fn internal_unauthenticated() -> Context { Context { kind: Kind::Unauthenticated, schemes_tried: vec![] } diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index 745a699cf2b..e8757a6b2aa 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -406,8 +406,66 @@ impl AuthorizedResource for IpPoolList { roleset: &'fut mut RoleSet, ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { // There are no roles on the IpPoolList, only permissions. But we still - // need to load the Fleet-related roles to verify that the actor has the - // "admin" role on the Fleet (possibly conferred from a Silo role). + // need to load the Fleet-related roles to verify that the actor's role + // on the Fleet (possibly conferred from a Silo role). + load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed() + } + + fn on_unauthorized( + &self, + _: &Authz, + error: Error, + _: AnyActor, + _: Action, + ) -> Error { + error + } + + fn polar_class(&self) -> oso::Class { + Self::get_polar_class() + } +} + +// Similar to IpPoolList, the audit log is a collection that doesn't exist in +// the database as an entity distinct from its children (IP pools, or in this +// case, audit log entries). We need a dummy resource here because we need +// something to hang permissions off of. We need to be able to create audit log +// children (entries) for login attempts, when there is no authenticated user, +// as well as for normal requests with an authenticated user. For retrieval, we +// want (to start out) to allow only fleet viewers to list children. + +#[derive(Clone, Copy, Debug)] +pub struct AuditLog; + +/// Singleton representing the [`AuditLog`] for authz purposes +pub const AUDIT_LOG: AuditLog = AuditLog; + +impl Eq for AuditLog {} + +impl PartialEq for AuditLog { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl oso::PolarClass for AuditLog { + fn get_polar_class_builder() -> oso::ClassBuilder { + oso::Class::builder() + .with_equality_check() + .add_attribute_getter("fleet", |_: &AuditLog| FLEET) + } +} + +impl AuthorizedResource for AuditLog { + fn load_roles<'fut>( + &'fut self, + opctx: &'fut OpContext, + authn: &'fut authn::Context, + roleset: &'fut mut RoleSet, + ) -> futures::future::BoxFuture<'fut, Result<(), Error>> { + // There are no roles on the AuditLog, only permissions. But we still + // need to load the Fleet-related roles to verify that the actor's role + // on the Fleet (possibly conferred from a Silo role). load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed() } diff --git a/nexus/auth/src/authz/omicron.polar b/nexus/auth/src/authz/omicron.polar index f9382401fdd..7a5d0e0d0e4 100644 --- a/nexus/auth/src/authz/omicron.polar +++ b/nexus/auth/src/authz/omicron.polar @@ -417,6 +417,25 @@ has_relation(fleet: Fleet, "parent_fleet", ip_pool_list: IpPoolList) has_permission(actor: AuthenticatedActor, "create_child", ip_pool: IpPool) if silo in actor.silo and silo.fleet = ip_pool.fleet; +# Describes the policy for reading and writing the audit log +resource AuditLog { + permissions = [ + "list_children", # retrieve audit log + "create_child", # create audit log entry + ]; + + relations = { parent_fleet: Fleet }; + + # Fleet viewers can read the audit log + "list_children" if "viewer" on "parent_fleet"; +} +# TODO: is this right? any op context should be able to write to the audit log? +# feels weird though +has_permission(_actor: AuthenticatedActor, "create_child", _audit_log: AuditLog); + +has_relation(fleet: Fleet, "parent_fleet", audit_log: AuditLog) + if audit_log.fleet = fleet; + # Describes the policy for creating and managing web console sessions. resource ConsoleSessionList { permissions = [ "create_child" ]; diff --git a/nexus/auth/src/authz/oso_generic.rs b/nexus/auth/src/authz/oso_generic.rs index 321bb98b1c6..769f2563e04 100644 --- a/nexus/auth/src/authz/oso_generic.rs +++ b/nexus/auth/src/authz/oso_generic.rs @@ -101,6 +101,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result { let classes = [ // Hand-written classes Action::get_polar_class(), + AuditLog::get_polar_class(), AnyActor::get_polar_class(), AuthenticatedActor::get_polar_class(), BlueprintConfig::get_polar_class(), diff --git a/nexus/db-model/src/audit_log.rs b/nexus/db-model/src/audit_log.rs new file mode 100644 index 00000000000..a2a890eb7cc --- /dev/null +++ b/nexus/db-model/src/audit_log.rs @@ -0,0 +1,142 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/5.0/. + +// Copyright 2025 Oxide Computer Company + +use crate::schema::audit_log; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use nexus_types::external_api::views; +use uuid::Uuid; + +#[derive(Queryable, Insertable, Selectable, Clone, Debug)] +#[diesel(table_name = audit_log)] +pub struct AuditLogEntry { + pub id: Uuid, + pub timestamp: DateTime, + pub request_id: String, + // TODO: this isn't in the RFD but it seems nice to have + pub request_uri: String, + + /// The API endpoint being logged, e.g., `project_create` + pub operation_id: String, + + pub source_ip: String, + pub resource_type: String, + + // TODO: we probably want a dedicated enum for these columns and for that + // we need a fancier set of columns. For example, we may want to initialize + // the row with a _potential_ actor (probably a different field), like the + // username or whatever is being used for login. This should probably be + // preserved even after authentication determines an actual actor ID. See + // the Actor struct in nexus/auth/src/authn/mod.ts + + // these are optional because of requests like login attempts, where there + // is no actor until after the operation. + pub actor_id: Option, + pub actor_silo_id: Option, + + /// API token or session cookie. Optional because it will not be defined + /// on unauthenticated requests like login attempts. + pub access_method: Option, + + // TODO: RFD 523 says: "Additionally, the response (or error) data should be + // included in the same log entry as the original request data. Separating + // the response from the request into two different log entries is extremely + // expensive for customers to identify which requests correspond to which + // responses." I guess the typical thing is to include a duration of the + // request rather than a second timestamp. + + // Seems like it has to be optional because at the beginning of the + // operation, we have not yet resolved the resource selector to an ID + pub resource_id: Option, + + // Fields that are optional because they get filled in after the action completes + /// Time log entry was completed with info about result of operation + pub time_completed: Option>, + + // Error information if the action failed + pub error_code: Option, + pub error_message: Option, + // TODO: including a real response complicates things + // Response data on success (if applicable) + // pub success_response: Option, +} + +impl AuditLogEntry { + pub fn new( + request_id: String, + operation_id: String, + request_uri: String, + source_ip: String, + actor_id: Option, + actor_silo_id: Option, + access_method: Option, + ) -> Self { + Self { + id: Uuid::new_v4(), + timestamp: Utc::now(), + request_id, + request_uri, + operation_id, + actor_id, + actor_silo_id, + source_ip, + access_method, + + // TODO: actually get all these values + resource_type: String::new(), + + // fields that can only be filled in after the operation + resource_id: None, + time_completed: None, + error_code: None, + error_message: None, + } + } +} + +#[derive(AsChangeset)] +#[diesel(table_name = audit_log)] +pub struct AuditLogCompletion { + pub time_completed: DateTime, +} + +impl AuditLogCompletion { + pub fn new() -> Self { + Self { time_completed: Utc::now() } + } +} + +// TODO: Add a struct representing only the fields set at log entry init time, +// use as an arg to the datastore init function to make misuse harder + +// TODO: AuditLogActor +// pub enum AuditLogActor { +// UserBuiltin { user_builtin_id: Uuid }, +// TODO: include info about computed roles at runtime? +// SiloUser { silo_user_id: Uuid, silo_id: Uuid }, +// Unauthenticated, +// } + +impl From for views::AuditLogEntry { + fn from(entry: AuditLogEntry) -> Self { + Self { + id: entry.id, + timestamp: entry.timestamp, + request_id: entry.request_id, + request_uri: entry.request_uri, + operation_id: entry.operation_id, + source_ip: entry.source_ip, + resource_type: entry.resource_type, + resource_id: entry.resource_id, + actor_id: entry.actor_id, + actor_silo_id: entry.actor_silo_id, + access_method: entry.access_method, + time_completed: entry.time_completed, + error_code: entry.error_code, + error_message: entry.error_message, + } + } +} diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index 99214a54c6f..ab0e2b86fd2 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -11,6 +11,7 @@ extern crate newtype_derive; mod address_lot; mod allow_list; +mod audit_log; mod bfd; mod bgp; mod block_size; @@ -131,6 +132,7 @@ pub use self::macaddr::*; pub use self::unsigned::*; pub use address_lot::*; pub use allow_list::*; +pub use audit_log::*; pub use bfd::*; pub use bgp::*; pub use block_size::*; diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index af9c09e4f21..3b95b3d4ac6 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -2134,3 +2134,22 @@ table! { region_snapshot_snapshot_id -> Nullable, } } + +table! { + audit_log (id) { + id -> Uuid, + timestamp -> Timestamptz, + request_id -> Text, + request_uri -> Text, + operation_id -> Text, + source_ip -> Text, + resource_type -> Text, + actor_id -> Nullable, + actor_silo_id -> Nullable, + access_method -> Nullable, + resource_id -> Nullable, + time_completed -> Nullable, + error_code -> Nullable, + error_message -> Nullable + } +} diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index c07f358194c..b57bdd92855 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(120, 0, 0); +pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(121, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -29,6 +29,7 @@ static KNOWN_VERSIONS: Lazy> = Lazy::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(121, "audit-log"), KnownVersion::new(120, "rendezvous-debug-dataset"), KnownVersion::new(119, "tuf-artifact-key-uuid"), KnownVersion::new(118, "support-bundles"), diff --git a/nexus/db-queries/src/db/datastore/audit_log.rs b/nexus/db-queries/src/db/datastore/audit_log.rs new file mode 100644 index 00000000000..611e7f8f954 --- /dev/null +++ b/nexus/db-queries/src/db/datastore/audit_log.rs @@ -0,0 +1,257 @@ +use super::DataStore; +use crate::authz; +use crate::db; +use crate::db::error::public_error_from_diesel; +use crate::db::model::AuditLogCompletion; +use crate::db::model::AuditLogEntry; +use crate::db::pagination::paginated_multicolumn; +use crate::{context::OpContext, db::error::ErrorHandler}; +use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use omicron_common::api::external::DataPageParams; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::ResourceType; +use omicron_common::api::external::{CreateResult, UpdateResult}; +use uuid::Uuid; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +impl DataStore { + pub async fn audit_log_list( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, (DateTime, Uuid)>, + start_time: DateTime, + end_time: Option>, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, &authz::AUDIT_LOG).await?; + + use db::schema::audit_log; + // TODO: this is sorting by timestamp, but in order to get stable sort + // with duplicate timestamps, we need to also sort by ID + let query = paginated_multicolumn( + audit_log::table, + (audit_log::timestamp, audit_log::id), + pagparams, + ) + .filter(audit_log::timestamp.ge(start_time)); + // TODO: confirm and document exclusive/inclusive behavior + let query = if let Some(end) = end_time { + query.filter(audit_log::timestamp.lt(end)) + } else { + query + }; + query + .select(AuditLogEntry::as_select()) + .load_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + + pub async fn audit_log_entry_init( + &self, + opctx: &OpContext, + entry: AuditLogEntry, + ) -> CreateResult { + use db::schema::audit_log; + opctx.authorize(authz::Action::CreateChild, &authz::AUDIT_LOG).await?; + + let entry_id = entry.id.to_string(); + + diesel::insert_into(audit_log::table) + .values(entry) + .returning(AuditLogEntry::as_returning()) + .get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::Conflict( + ResourceType::AuditLogEntry, + &entry_id, + ), + ) + }) + } + + // set duration and result on an existing entry + pub async fn audit_log_entry_complete( + &self, + opctx: &OpContext, + entry: &AuditLogEntry, + completion: AuditLogCompletion, + ) -> UpdateResult<()> { + use db::schema::audit_log; + opctx.authorize(authz::Action::CreateChild, &authz::AUDIT_LOG).await?; + diesel::update(audit_log::table) + .filter(audit_log::id.eq(entry.id)) + .set(completion) + .execute_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + Ok(()) // TODO: make sure we don't want to return something else + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::pub_test_utils::TestDatabase; + use assert_matches::assert_matches; + use omicron_common::api::external::Error; + use omicron_test_utils::dev; + use std::num::NonZeroU32; + + #[tokio::test] + async fn test_audit_log_basic() { + let logctx = dev::test_setup_log("test_audit_log"); + let log = &logctx.log; + let db = TestDatabase::new_with_datastore(log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + let pagparams = DataPageParams { + marker: None, + limit: NonZeroU32::new(100).unwrap(), + direction: dropshot::PaginationOrder::Ascending, + }; + let t0: DateTime = Utc::now(); + let t_future: DateTime = "2099-01-01T00:00:00Z".parse().unwrap(); + + let audit_log = datastore + .audit_log_list(opctx, &pagparams, t0, None) + .await + .expect("retrieve empty audit log"); + assert_eq!(audit_log.len(), 0); + + let audit_log = datastore + .audit_log_list(opctx, &pagparams, t_future, None) + .await + .expect("retrieve empty audit log"); + assert_eq!(audit_log.len(), 0); + + let entry1 = AuditLogEntry::new( + "req-1".to_string(), + "project_create".to_string(), + "https://omicron.com/projects".to_string(), + "1.1.1.1".to_string(), + None, + None, + None, + ); + datastore + .audit_log_entry_init(opctx, entry1.clone()) + .await + .expect("init audit log entry"); + + // inserting the same entry again blows up + let conflict = datastore + .audit_log_entry_init(opctx, entry1) + .await + .expect_err("inserting same entry again should error"); + assert_matches!(conflict, Error::ObjectAlreadyExists { .. }); + + let t1 = Utc::now(); + + let entry2 = AuditLogEntry::new( + "req-2".to_string(), + "project_delete".to_string(), + "https://omicron.com/projects/123".to_string(), + "1.1.1.1".to_string(), + None, + None, + None, + ); + datastore + .audit_log_entry_init(opctx, entry2.clone()) + .await + .expect("init second audit log entry"); + + // get both entries + let audit_log = datastore + .audit_log_list(opctx, &pagparams, t0, None) + .await + .expect("retrieve audit log"); + assert_eq!(audit_log.len(), 2); + assert_eq!(audit_log[0].request_id, "req-1"); + assert_eq!(audit_log[1].request_id, "req-2"); + + // Only get first entry + let audit_log = datastore + .audit_log_list(opctx, &pagparams, t0, Some(t1)) + .await + .expect("retrieve first audit log entry"); + assert_eq!(audit_log.len(), 1); + assert_eq!(audit_log[0].request_id, "req-1"); + + // Only get second entry + let audit_log = datastore + .audit_log_list(opctx, &pagparams, t1, None) + .await + .expect("retrieve second audit log entry"); + assert_eq!(audit_log.len(), 1); + assert_eq!(audit_log[0].request_id, "req-2"); + + db.terminate().await; + logctx.cleanup_successful(); + } + + #[tokio::test] + async fn test_audit_log_order_by_id_also() { + let logctx = + dev::test_setup_log("test_audit_log_non_unique_timestamps"); + let log = &logctx.log; + let db = TestDatabase::new_with_datastore(log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + let t0 = Utc::now(); + + let base = AuditLogEntry::new( + "req-1".to_string(), + "project_create".to_string(), + "https://omicron.com/projects".to_string(), + "1.1.1.1".to_string(), + None, + None, + None, + ); + + let id1 = "1710a22e-b29b-4cfc-9e79-e8c93be187d7"; + let id2 = "5d25e766-e026-44b4-8b42-5f90f43c26bc"; + let id3 = "a156ad37-047e-4028-88bd-8034906d5a27"; + let id4 = "d0d59e4f-4c98-4df5-b3c5-39fc0c2ac547"; + + // funky order so we can feel really good about the sort order being correct + for id in [id4, id1, id3, id2] { + let entry = + AuditLogEntry { id: id.parse().unwrap(), ..base.clone() }; + datastore + .audit_log_entry_init(opctx, entry) + .await + .expect("init entry"); + } + + let pagparams = DataPageParams { + marker: None, + limit: NonZeroU32::new(100).unwrap(), + direction: dropshot::PaginationOrder::Ascending, + }; + + // retrieve both and check the order -- the one with the lower ID + // should always be first + let audit_log = datastore + .audit_log_list(opctx, &pagparams, t0, None) + .await + .expect("retrieve audit log"); + assert_eq!(audit_log.len(), 4); + assert_eq!(audit_log[0].id.to_string(), id1); + assert_eq!(audit_log[1].id.to_string(), id2); + assert_eq!(audit_log[2].id.to_string(), id3); + assert_eq!(audit_log[3].id.to_string(), id4); + + db.terminate().await; + logctx.cleanup_successful(); + } +} diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index d14eb58ea6f..16ff1b69886 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -50,6 +50,7 @@ use std::sync::Arc; mod address_lot; mod allow_list; +mod audit_log; mod auth; mod bfd; mod bgp; diff --git a/nexus/db-queries/src/policy_test/resource_builder.rs b/nexus/db-queries/src/policy_test/resource_builder.rs index b6d7d97553e..f3f78a55743 100644 --- a/nexus/db-queries/src/policy_test/resource_builder.rs +++ b/nexus/db-queries/src/policy_test/resource_builder.rs @@ -284,6 +284,7 @@ impl_dyn_authorized_resource_for_global!(authz::ConsoleSessionList); impl_dyn_authorized_resource_for_global!(authz::DeviceAuthRequestList); impl_dyn_authorized_resource_for_global!(authz::DnsConfig); impl_dyn_authorized_resource_for_global!(authz::IpPoolList); +impl_dyn_authorized_resource_for_global!(authz::AuditLog); impl_dyn_authorized_resource_for_global!(authz::Inventory); impl DynAuthorizedResource for authz::SiloCertificateList { diff --git a/nexus/db-queries/src/policy_test/resources.rs b/nexus/db-queries/src/policy_test/resources.rs index 6ee92e167cf..ed3318faed5 100644 --- a/nexus/db-queries/src/policy_test/resources.rs +++ b/nexus/db-queries/src/policy_test/resources.rs @@ -73,6 +73,7 @@ pub async fn make_resources( builder.new_resource(authz::DEVICE_AUTH_REQUEST_LIST); builder.new_resource(authz::INVENTORY); builder.new_resource(authz::IP_POOL_LIST); + builder.new_resource(authz::AUDIT_LOG); // Silo/organization/project hierarchy make_silo(&mut builder, "silo1", main_silo_id, true).await; diff --git a/nexus/db-queries/tests/output/authz-roles.out b/nexus/db-queries/tests/output/authz-roles.out index 4b24e649ccb..ebc182765dc 100644 --- a/nexus/db-queries/tests/output/authz-roles.out +++ b/nexus/db-queries/tests/output/authz-roles.out @@ -110,6 +110,20 @@ resource: authz::IpPoolList silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✘ ✘ unauthenticated ! ! ! ! ! ! ! ! +resource: authz::AuditLog + + USER Q R LC RP M MP CC D + fleet-admin ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + fleet-collaborator ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + fleet-viewer ✘ ✘ ✔ ✘ ✘ ✘ ✔ ✘ + silo1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ + silo1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ + silo1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ + silo1-proj1-admin ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ + silo1-proj1-collaborator ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ + silo1-proj1-viewer ✘ ✘ ✘ ✘ ✘ ✘ ✔ ✘ + unauthenticated ! ! ! ! ! ! ! ! + resource: Silo "silo1" USER Q R LC RP M MP CC D diff --git a/nexus/external-api/Cargo.toml b/nexus/external-api/Cargo.toml index 0875e1f5742..d702d1f0494 100644 --- a/nexus/external-api/Cargo.toml +++ b/nexus/external-api/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] anyhow.workspace = true +chrono.workspace = true dropshot.workspace = true http.workspace = true hyper.workspace = true diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 4fc92b18d8a..fab7bddafcf 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -135,6 +135,10 @@ snapshot_delete DELETE /v1/snapshots/{snapshot} snapshot_list GET /v1/snapshots snapshot_view GET /v1/snapshots/{snapshot} +API operations found with tag "system/audit-log" +OPERATION ID METHOD URL PATH +audit_log_list GET /v1/system/audit-log + API operations found with tag "system/hardware" OPERATION ID METHOD URL PATH networking_switch_port_apply_settings POST /v1/system/hardware/switch-port/{port}/settings diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index bc453a97e8a..0055e367407 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use anyhow::anyhow; +use chrono::{DateTime, Utc}; use dropshot::Body; use dropshot::{ EmptyScanParams, EndpointTagPolicy, HttpError, HttpResponseAccepted, @@ -11,6 +12,7 @@ use dropshot::{ WebsocketChannelResult, WebsocketConnection, }; use http::Response; +use http_pagination::{PageSelector, PaginatedByTimestampAndId}; use ipnetwork::IpNetwork; use nexus_types::{ authn::cookies::Cookies, @@ -158,6 +160,12 @@ const PUT_UPDATE_REPOSITORY_MAX_BYTES: usize = 4 * GIB; url = "http://docs.oxide.computer/api/vpcs" } }, + "system/audit-log" = { + description = "These endpoints relate to audit logs.", + external_docs = { + url = "http://docs.oxide.computer/api/system-audit-log" + } + }, "system/probes" = { description = "Probes for testing network connectivity", external_docs = { @@ -2941,6 +2949,19 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result; + // Audit logging + + /// View audit log + #[endpoint { + method = GET, + path = "/v1/system/audit-log", + tags = ["system/audit-log"], + }] + async fn audit_log_list( + rqctx: RequestContext, + query_params: Query>, + ) -> Result>, HttpError>; + // Console API: logins /// SAML login console page (just a link to the IdP) @@ -3313,3 +3334,8 @@ pub type IpPoolRangePaginationParams = /// Type used to paginate request to list timeseries schema pub type TimeseriesSchemaPaginationParams = PaginationParams; + +pub type AuditLogPaginationParams = PaginationParams< + params::AuditLog, + PageSelector>, +>; diff --git a/nexus/src/app/audit_log.rs b/nexus/src/app/audit_log.rs new file mode 100644 index 00000000000..1a24fba8f8c --- /dev/null +++ b/nexus/src/app/audit_log.rs @@ -0,0 +1,67 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use chrono::{DateTime, Utc}; +use dropshot::RequestContext; +use nexus_db_model::{AuditLogCompletion, AuditLogEntry}; +use nexus_db_queries::context::OpContext; +use omicron_common::api::external::{ + CreateResult, DataPageParams, ListResultVec, UpdateResult, +}; +use uuid::Uuid; + +use crate::context::ApiContext; + +impl super::Nexus { + // Currently this pulls from CRDB only, but the idea is that we are + // only storing recent entries in CRDB and moving the data in batches + // to clickhouse in a job. In that case we would need to look at both + // clickhouse and CRDB. We could potentially skip the CRDB part if we're + // confident the range excludes CRDB data, but it's probably simpler to + // always check CRDB. + + pub(crate) async fn audit_log_list( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, (DateTime, Uuid)>, + start_time: DateTime, + end_time: Option>, + ) -> ListResultVec { + self.db_datastore + .audit_log_list(opctx, pagparams, start_time, end_time) + .await + } + + pub(crate) async fn audit_log_entry_init( + &self, + opctx: &OpContext, + // TODO: not sure we want the app layer to be aware of RequestContext. + // might be better to extract the relevant fields at the call site. still + // would want a helper to avoid duplication + rqctx: &RequestContext, + ) -> CreateResult { + let actor = opctx.authn.actor(); + let entry = AuditLogEntry::new( + rqctx.request_id.clone(), + rqctx.endpoint.operation_id.clone(), + rqctx.request.uri().to_string(), + rqctx.request.remote_addr().ip().to_string(), + actor.map(|a| a.actor_id()), + actor.and_then(|a| a.silo_id()), + opctx.authn.scheme_used().map(|s| s.to_string()), + ); + self.db_datastore.audit_log_entry_init(opctx, entry).await + } + + // set duration and result on an existing entry + pub(crate) async fn audit_log_entry_complete( + /* id, duration, result */ + &self, + opctx: &OpContext, + entry: &AuditLogEntry, + ) -> UpdateResult<()> { + let update = AuditLogCompletion::new(); + self.db_datastore.audit_log_entry_complete(opctx, &entry, update).await + } +} diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 636a47f14a8..9679ca267ae 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -46,6 +46,7 @@ use uuid::Uuid; // by resource. mod address_lot; mod allow_list; +mod audit_log; pub(crate) mod background; mod bfd; mod bgp; diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 1d43dc3bdde..40b637f9835 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -53,8 +53,6 @@ use nexus_types::{ shared::{BfdStatus, ProbeInfo}, }, }; -use omicron_common::api::external::http_pagination::data_page_params_for; -use omicron_common::api::external::http_pagination::marker_for_name; use omicron_common::api::external::http_pagination::marker_for_name_or_id; use omicron_common::api::external::http_pagination::name_or_id_pagination; use omicron_common::api::external::http_pagination::PaginatedBy; @@ -65,6 +63,12 @@ use omicron_common::api::external::http_pagination::ScanById; use omicron_common::api::external::http_pagination::ScanByName; use omicron_common::api::external::http_pagination::ScanByNameOrId; use omicron_common::api::external::http_pagination::ScanParams; +use omicron_common::api::external::http_pagination::{ + data_page_params_for, PaginatedByTimestampAndId, +}; +use omicron_common::api::external::http_pagination::{ + marker_for_name, ScanByTimestampAndId, +}; use omicron_common::api::external::AddressLot; use omicron_common::api::external::AddressLotBlock; use omicron_common::api::external::AddressLotCreateResponse; @@ -854,13 +858,22 @@ impl NexusExternalApi for NexusExternalApiImpl { new_project: TypedBody, ) -> Result, HttpError> { let apictx = rqctx.context(); - let nexus = &apictx.context.nexus; let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let project = - nexus.project_create(&opctx, &new_project.into_inner()).await?; - Ok(HttpResponseCreated(project.into())) + let nexus = &apictx.context.nexus; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; + + let result = async { + let project = nexus + .project_create(&opctx, &new_project.into_inner()) + .await?; + Ok(HttpResponseCreated(project.into())) + } + .await; + + let _ = nexus.audit_log_entry_complete(&opctx, &audit).await; + result }; apictx .context @@ -6428,6 +6441,46 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn audit_log_list( + rqctx: RequestContext, + query_params: Query>, + ) -> Result>, HttpError> + { + let apictx = rqctx.context(); + let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + opctx.authorize(authz::Action::Read, &authz::FLEET).await?; + + let nexus = &apictx.context.nexus; + let query = query_params.into_inner(); + let scan_params = ScanByTimestampAndId::from_query(&query)?; + let pag_params = data_page_params_for(&rqctx, &query)?; + + let log_entries = nexus + .audit_log_list( + &opctx, + &pag_params, + scan_params.selector.start_time, + scan_params.selector.end_time, + ) + .await? + .into_iter() + .map(|entry| entry.into()) + .collect::>(); + Ok(HttpResponseOk(ScanByTimestampAndId::results_page( + &query, + log_entries, + &|_, entry: &views::AuditLogEntry| (entry.timestamp, entry.id), + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn login_saml_begin( rqctx: RequestContext, _path_params: Path, @@ -6481,36 +6534,45 @@ impl NexusExternalApi for NexusExternalApiImpl { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; - let path_params = path_params.into_inner(); // By definition, this request is not authenticated. These operations // happen using the Nexus "external authentication" context, which we // keep specifically for this purpose. let opctx = nexus.opctx_external_authn(); - let (session, next_url) = nexus - .login_saml( - opctx, - body_bytes, - &path_params.silo_name.into(), - &path_params.provider_name.into(), - ) - .await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; - let mut response = http_response_see_other(next_url)?; - { - let headers = response.headers_mut(); - let cookie = session_cookie::session_cookie_header_value( - &session.token, - // use absolute timeout even though session might idle out first. - // browser expiration is mostly for convenience, as the API will - // reject requests with an expired session regardless - apictx.context.session_absolute_timeout(), - apictx.context.external_tls_enabled, - )?; - headers.append(header::SET_COOKIE, cookie); + let result = async { + let path_params = path_params.into_inner(); + let (session, next_url) = nexus + .login_saml( + opctx, + body_bytes, + &path_params.silo_name.into(), + &path_params.provider_name.into(), + ) + .await?; + + let mut response = http_response_see_other(next_url)?; + { + let headers = response.headers_mut(); + let cookie = session_cookie::session_cookie_header_value( + &session.token, + // use absolute timeout even though session might idle out first. + // browser expiration is mostly for convenience, as the API will + // reject requests with an expired session regardless + apictx.context.session_absolute_timeout(), + apictx.context.external_tls_enabled, + )?; + headers.append(header::SET_COOKIE, cookie); + } + Ok(response) } - Ok(response) + .await; + + let _ = nexus.audit_log_entry_complete(&opctx, &audit).await; + + result }; apictx .context @@ -6542,36 +6604,44 @@ impl NexusExternalApi for NexusExternalApiImpl { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; - let path = path_params.into_inner(); - let credentials = credentials.into_inner(); - let silo = path.silo_name.into(); - // By definition, this request is not authenticated. These operations // happen using the Nexus "external authentication" context, which we // keep specifically for this purpose. let opctx = nexus.opctx_external_authn(); - let silo_lookup = nexus.silo_lookup(&opctx, silo)?; - let user = - nexus.login_local(&opctx, &silo_lookup, credentials).await?; + let audit = nexus.audit_log_entry_init(&opctx, &rqctx).await?; - let session = nexus.create_session(opctx, user).await?; - let mut response = HttpResponseHeaders::new_unnamed( - HttpResponseUpdatedNoContent(), - ); + let result = async { + let path = path_params.into_inner(); + let credentials = credentials.into_inner(); + let silo = path.silo_name.into(); - { - let headers = response.headers_mut(); - let cookie = session_cookie::session_cookie_header_value( - &session.token, - // use absolute timeout even though session might idle out first. - // browser expiration is mostly for convenience, as the API will - // reject requests with an expired session regardless - apictx.context.session_absolute_timeout(), - apictx.context.external_tls_enabled, - )?; - headers.append(header::SET_COOKIE, cookie); + let silo_lookup = nexus.silo_lookup(&opctx, silo)?; + let user = nexus + .login_local(&opctx, &silo_lookup, credentials) + .await?; + + let session = nexus.create_session(opctx, user).await?; + let mut response = HttpResponseHeaders::new_unnamed( + HttpResponseUpdatedNoContent(), + ); + + { + let headers = response.headers_mut(); + let cookie = session_cookie::session_cookie_header_value( + &session.token, + // use absolute timeout even though session might idle out first. + // browser expiration is mostly for convenience, as the API will + // reject requests with an expired session regardless + apictx.context.session_absolute_timeout(), + apictx.context.external_tls_enabled, + )?; + headers.append(header::SET_COOKIE, cookie); + } + Ok(response) } - Ok(response) + .await; + let _ = nexus.audit_log_entry_complete(&opctx, &audit).await; + result }; apictx .context @@ -6805,6 +6875,14 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + // TODO: decide whether to audit log the handler above or below. Both have + // their issues. For example, device_access_token is meant to be polled + // until the token is available (due to login success elsewhere). That means + // there are a bunch of requests that don't really deserve the audit log + // entry. For that reason, device_auth_confirm is probably more plausible + // because there's only one call and one success, and it's where the token + // is actually created. + async fn device_access_token( rqctx: RequestContext, params: TypedBody, diff --git a/nexus/tests/integration_tests/audit_log.rs b/nexus/tests/integration_tests/audit_log.rs new file mode 100644 index 00000000000..7e9cd0b5c8a --- /dev/null +++ b/nexus/tests/integration_tests/audit_log.rs @@ -0,0 +1,96 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use chrono::{DateTime, Utc}; +use dropshot::{test_util::ClientTestContext, ResultsPage}; +use nexus_db_queries::authn::USER_TEST_PRIVILEGED; +use nexus_test_utils::resource_helpers::{ + create_project, objects_list_page_authz, +}; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::views; +use nexus_types::{identity::Asset, silo::DEFAULT_SILO_ID}; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +fn to_q(d: DateTime) -> String { + d.to_rfc3339_opts(chrono::SecondsFormat::Micros, true) +} + +async fn fetch_log( + client: &ClientTestContext, + start: DateTime, + end: Option>, +) -> ResultsPage { + let mut qs = vec![format!("start_time={}", to_q(start))]; + if let Some(end) = end { + qs.push(format!("end_time={}", to_q(end))); + } + let url = format!("/v1/system/audit-log?{}", qs.join("&")); + objects_list_page_authz::(client, &url).await +} + +#[nexus_test] +async fn test_audit_log_list(ctx: &ControlPlaneTestContext) { + let client = &ctx.external_client; + + let t0: DateTime = "2024-01-01T00:00:00Z".parse().unwrap(); + // let t_future: DateTime = "2099-01-01T00:00:00Z".parse().unwrap(); + + let audit_log = fetch_log(client, t0, None).await; + assert_eq!(audit_log.items.len(), 0); + + let t1 = Utc::now(); // before log entry + + // this endpoint has audit log calls in it + create_project(client, "test-proj").await; + + let t2 = Utc::now(); // after log entry + + let audit_log = fetch_log(client, t0, None).await; + assert_eq!(audit_log.items.len(), 1); + + // this endpoint has audit log calls in it + create_project(client, "test-proj2").await; + + let t3 = Utc::now(); // after second entry + + let audit_log = dbg!(fetch_log(client, t1, None).await); + assert_eq!(audit_log.items.len(), 2); + + let e1 = &audit_log.items[0]; + let e2 = &audit_log.items[1]; + + assert_eq!(e1.request_uri, "/v1/projects"); + assert_eq!(e1.operation_id, "project_create"); + assert_eq!(e1.source_ip, "127.0.0.1"); + assert_eq!(e1.resource_type, ""); + // TODO: would be nice to test a request with a different method + assert_eq!(e1.access_method, Some("spoof".to_string())); + assert!(e1.timestamp >= t1 && e1.timestamp <= t2); + assert!(e1.time_completed.unwrap() > e1.timestamp); + assert_eq!(e1.actor_id, Some(USER_TEST_PRIVILEGED.id())); + assert_eq!(e1.actor_silo_id, Some(DEFAULT_SILO_ID)); + + assert_eq!(e2.request_uri, "/v1/projects"); + assert_eq!(e2.operation_id, "project_create"); + assert_eq!(e2.source_ip, "127.0.0.1"); + assert_eq!(e2.resource_type, ""); + assert_eq!(e2.access_method, Some("spoof".to_string())); + assert!(e2.timestamp >= t2 && e2.timestamp <= t3); + assert!(e2.time_completed.unwrap() > e2.timestamp); + assert_eq!(e2.actor_id, Some(USER_TEST_PRIVILEGED.id())); + assert_eq!(e2.actor_silo_id, Some(DEFAULT_SILO_ID)); + + // we can exclude the entry by timestamp + let audit_log = fetch_log(client, t2, Some(t2)).await; + assert_eq!(audit_log.items.len(), 0); + + let audit_log = fetch_log(client, t2, None).await; + assert_eq!(audit_log.items.len(), 1); + + // TODO: assert about list order + // TODO: test pagination cursor +} diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 656b4ba8266..2ad1b11862e 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -982,6 +982,10 @@ pub static ALLOW_LIST_UPDATE: Lazy = Lazy::new(|| { params::AllowListUpdate { allowed_ips: AllowedSourceIps::Any } }); +pub static AUDIT_LOG_URL: Lazy = Lazy::new(|| { + String::from("/v1/system/audit-log?start_time=2025-01-01T00:00:00Z") +}); + /// Describes an API endpoint to be verified by the "unauthorized" test /// /// These structs are also used to check whether we're covering all endpoints in @@ -2644,5 +2648,15 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { ), ], }, + + // Audit log + VerifyEndpoint { + url: &AUDIT_LOG_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + ], + }, ] }); diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index dc404736cd1..8989ad3955c 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -5,6 +5,7 @@ mod address_lots; mod allow_list; +mod audit_log; mod authn_http; mod authz; mod basic; diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index d4d09ad46a7..fe9d9724d6b 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -2287,3 +2287,12 @@ pub struct DeviceAccessTokenRequest { pub device_code: String, pub client_id: Uuid, } + +// Audit log has its own pagination scheme because it paginates by timestamp. +#[derive(Deserialize, JsonSchema, Serialize, PartialEq, Debug, Clone)] +pub struct AuditLog { + /// Required, inclusive + pub start_time: DateTime, + /// Exclusive + pub end_time: Option>, +} diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 94b2279906d..b4aeff54510 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -1027,3 +1027,44 @@ pub struct OxqlQueryResult { /// Tables resulting from the query, each containing timeseries. pub tables: Vec, } + +/// Audit log entry +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct AuditLogEntry { + /// Unique identifier for the audit log entry + pub id: Uuid, + + /// When the request was received + pub timestamp: DateTime, + + /// Request ID for tracing requests through the system + pub request_id: String, + /// Full URL of the request + pub request_uri: String, + /// API endpoint ID, e.g., `project_create` + pub operation_id: String, + /// IP address that made the request + pub source_ip: String, + /// Resource type + pub resource_type: String, + + /// User ID of the actor who performed the action + pub actor_id: Option, + pub actor_silo_id: Option, + + /// API token or session cookie. Optional because it will not be defined on + /// unauthenticated requests like login attempts. + pub access_method: Option, + + // Fields that are optional because they get filled in after the action completes + /// Resource identifier + pub resource_id: Option, + + // TODO: document that we paginate by time_completed once that change is made + /// Time operation completed + pub time_completed: Option>, + + /// Error information if the action failed + pub error_code: Option, + pub error_message: Option, +} diff --git a/openapi/nexus.json b/openapi/nexus.json index bc043059dd2..f2b824c8f8c 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -5036,6 +5036,86 @@ } } }, + "/v1/system/audit-log": { + "get": { + "tags": [ + "system/audit-log" + ], + "summary": "View audit log", + "operationId": "audit_log_list", + "parameters": [ + { + "in": "query", + "name": "end_time", + "description": "Exclusive", + "schema": { + "nullable": true, + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/TimestampAndIdSortMode" + } + }, + { + "in": "query", + "name": "start_time", + "description": "Required, inclusive", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditLogEntryResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [ + "start_time" + ] + } + } + }, "/v1/system/hardware/disks": { "get": { "tags": [ @@ -11082,6 +11162,109 @@ } ] }, + "AuditLogEntry": { + "description": "Audit log entry", + "type": "object", + "properties": { + "access_method": { + "nullable": true, + "description": "API token or session cookie. Optional because it will not be defined on unauthenticated requests like login attempts.", + "type": "string" + }, + "actor_id": { + "nullable": true, + "description": "User ID of the actor who performed the action", + "type": "string", + "format": "uuid" + }, + "actor_silo_id": { + "nullable": true, + "type": "string", + "format": "uuid" + }, + "error_code": { + "nullable": true, + "description": "Error information if the action failed", + "type": "string" + }, + "error_message": { + "nullable": true, + "type": "string" + }, + "id": { + "description": "Unique identifier for the audit log entry", + "type": "string", + "format": "uuid" + }, + "operation_id": { + "description": "API endpoint ID, e.g., `project_create`", + "type": "string" + }, + "request_id": { + "description": "Request ID for tracing requests through the system", + "type": "string" + }, + "request_uri": { + "description": "Full URL of the request", + "type": "string" + }, + "resource_id": { + "nullable": true, + "description": "Resource identifier", + "type": "string", + "format": "uuid" + }, + "resource_type": { + "description": "Resource type", + "type": "string" + }, + "source_ip": { + "description": "IP address that made the request", + "type": "string" + }, + "time_completed": { + "nullable": true, + "description": "Time operation completed", + "type": "string", + "format": "date-time" + }, + "timestamp": { + "description": "When the request was received", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "operation_id", + "request_id", + "request_uri", + "resource_type", + "source_ip", + "timestamp" + ] + }, + "AuditLogEntryResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditLogEntry" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "AuthzScope": { "description": "Authorization scope for a timeseries.\n\nThis describes the level at which a user must be authorized to read data from a timeseries. For example, fleet-scoping means the data is only visible to an operator or fleet reader. Project-scoped, on the other hand, indicates that a user will see data limited to the projects on which they have read permissions.", "oneOf": [ @@ -22956,6 +23139,18 @@ "ram_provisioned" ] }, + "TimestampAndIdSortMode": { + "description": "Supported set of sort modes for scanning by timestamp and ID\n\nCurrently, we only support scanning in ascending order.", + "oneOf": [ + { + "description": "sort in increasing order of timestamp and ID", + "type": "string", + "enum": [ + "ascending" + ] + } + ] + }, "NameSortMode": { "description": "Supported set of sort modes for scanning by name only\n\nCurrently, we only support scanning in ascending order.", "oneOf": [ @@ -23074,6 +23269,13 @@ "url": "http://docs.oxide.computer/api/snapshots" } }, + { + "name": "system/audit-log", + "description": "These endpoints relate to audit logs.", + "externalDocs": { + "url": "http://docs.oxide.computer/api/system-audit-log" + } + }, { "name": "system/hardware", "description": "These operations pertain to hardware inventory and management. Racks are the unit of expansion of an Oxide deployment. Racks are in turn composed of sleds, switches, power supplies, and a cabled backplane.", diff --git a/schema/crdb/audit-log/up01.sql b/schema/crdb/audit-log/up01.sql new file mode 100644 index 00000000000..192cd09b09b --- /dev/null +++ b/schema/crdb/audit-log/up01.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + -- TODO: sizes on all strings + request_id STRING NOT NULL, + request_uri STRING NOT NULL, + operation_id STRING NOT NULL, + source_ip STRING NOT NULL, + resource_type STRING NOT NULL, + + actor_id UUID, + actor_silo_id UUID, + access_method STRING, + + -- fields we can only fill in after the operation + resource_id UUID, + time_completed TIMESTAMPTZ, + error_code STRING, + error_message STRING, + -- this stuff avoids table scans when filtering and sorting by timestamp + -- sequential field must go after the random field + -- https://www.cockroachlabs.com/docs/v22.1/performance-best-practices-overview#use-multi-column-primary-keys + -- https://www.cockroachlabs.com/docs/v22.1/hash-sharded-indexes#create-a-table-with-a-hash-sharded-secondary-index + PRIMARY KEY (id, timestamp), + INDEX (timestamp) USING HASH +); diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index ec4300b1fa0..7c15d3b62ad 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -4804,6 +4804,33 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_record_per_volume_resource_usage on omicro region_snapshot_snapshot_id ); +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + -- TODO: sizes on all strings + request_id STRING NOT NULL, + request_uri STRING NOT NULL, + operation_id STRING NOT NULL, + source_ip STRING NOT NULL, + resource_type STRING NOT NULL, + + actor_id UUID, + actor_silo_id UUID, + access_method STRING, + + -- fields we can only fill in after the operation + resource_id UUID, + time_completed TIMESTAMPTZ, + error_code STRING, + error_message STRING, + -- this stuff avoids table scans when filtering and sorting by timestamp + -- sequential field must go after the random field + -- https://www.cockroachlabs.com/docs/v22.1/performance-best-practices-overview#use-multi-column-primary-keys + -- https://www.cockroachlabs.com/docs/v22.1/hash-sharded-indexes#create-a-table-with-a-hash-sharded-secondary-index + PRIMARY KEY (id, timestamp), + INDEX (timestamp) USING HASH +); + /* * Keep this at the end of file so that the database does not contain a version * until it is fully populated. @@ -4815,7 +4842,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '120.0.0', NULL) + (TRUE, NOW(), NOW(), '121.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT;