Skip to content

Commit

Permalink
initial audit log endpoints, data model, tests
Browse files Browse the repository at this point in the history
  • Loading branch information
david-crespo committed Jan 15, 2025
1 parent 1c92838 commit 1c4e5bf
Show file tree
Hide file tree
Showing 30 changed files with 1,088 additions and 6 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

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

52 changes: 52 additions & 0 deletions common/src/api/external/http_pagination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -409,6 +411,56 @@ impl<
}
}

/// Query parameters for pagination by timestamp and ID
pub type PaginatedByTimestampAndId<Selector = ()> = PaginationParams<
ScanByTimestampAndId<Selector>,
PageSelectorByTimestampAndId<Selector>,
>;
/// Page selector for pagination by name only
pub type PageSelectorByTimestampAndId<Selector = ()> =
PageSelector<ScanByTimestampAndId<Selector>, (DateTime<Utc>, Uuid)>;

/// Scan parameters for resources that support scanning by (timestamp, id)
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
pub struct ScanByTimestampAndId<Selector = ()> {
#[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 "name"
Ascending,
}

fn default_ts_id_sort_mode() -> TimestampAndIdSortMode {
TimestampAndIdSortMode::Ascending
}

impl<
T: Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize,
> ScanParams for ScanByTimestampAndId<T>
{
type MarkerValue = (DateTime<Utc>, Uuid);
fn direction(&self) -> PaginationOrder {
PaginationOrder::Ascending
}
fn from_query(
p: &PaginatedByTimestampAndId<T>,
) -> 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;
Expand Down
1 change: 1 addition & 0 deletions common/src/api/external/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,7 @@ pub enum ResourceType {
AddressLot,
AddressLotBlock,
AllowList,
AuditLogEntry,
BackgroundTask,
BgpConfig,
BgpAnnounceSet,
Expand Down
62 changes: 60 additions & 2 deletions nexus/auth/src/authz/api_resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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()
}

Expand Down
19 changes: 19 additions & 0 deletions nexus/auth/src/authz/omicron.polar
Original file line number Diff line number Diff line change
Expand Up @@ -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" ];
Expand Down
1 change: 1 addition & 0 deletions nexus/auth/src/authz/oso_generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<OsoInit, anyhow::Error> {
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(),
Expand Down
128 changes: 128 additions & 0 deletions nexus/db-model/src/audit_log.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// 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, TimeDelta, 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<Utc>,
pub request_id: String,
// TODO: this isn't in the RFD but it seems nice to have
pub request_uri: String,
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<Uuid>,
pub actor_silo_id: Option<Uuid>,

/// The specific action that was attempted (create, delete, update, etc)
pub action: String, // TODO: enum type?

// TODO: we will need to add headers in the client to get this info
// How the actor authenticated (api_key, console, etc)
// pub access_method: String,

// 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<Uuid>,

// Fields that are optional because they get filled in after the action completes
/// Time in milliseconds between receiving request and responding
pub duration: Option<TimeDelta>,

// Error information if the action failed
pub error_code: Option<String>,
pub error_message: Option<String>,
// TODO: including a real response complicates things
// Response data on success (if applicable)
// pub success_response: Option<Value>,
}

impl AuditLogEntry {
pub fn new(
request_id: String,
operation_id: String,
request_uri: String,
actor_id: Option<Uuid>,
actor_silo_id: Option<Uuid>,
) -> Self {
Self {
id: Uuid::new_v4(),
timestamp: Utc::now(),
request_id,
request_uri,
operation_id,
actor_id,
actor_silo_id,

// TODO: actually get all these values
source_ip: String::new(),
resource_type: String::new(),
action: String::new(),

// fields that can only be filled in after the operation
resource_id: None,
duration: None,
error_code: None,
error_message: None,
}
}
}

// 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<AuditLogEntry> 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,
action: entry.action,
duration_ms: entry.duration.map(|d| d.num_milliseconds()),
error_code: entry.error_code,
error_message: entry.error_message,
}
}
}
2 changes: 2 additions & 0 deletions nexus/db-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extern crate newtype_derive;

mod address_lot;
mod allow_list;
mod audit_log;
mod bfd;
mod bgp;
mod block_size;
Expand Down Expand Up @@ -130,6 +131,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::*;
Expand Down
19 changes: 19 additions & 0 deletions nexus/db-model/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2122,3 +2122,22 @@ table! {
region_snapshot_snapshot_id -> Nullable<Uuid>,
}
}

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<Uuid>,
actor_silo_id -> Nullable<Uuid>,
action -> Text,
resource_id -> Nullable<Uuid>,
duration -> Nullable<Interval>,
error_code -> Nullable<Text>,
error_message -> Nullable<Text>
}
}
3 changes: 2 additions & 1 deletion nexus/db-model/src/schema_versions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(119, 0, 0);
pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(120, 0, 0);

/// List of all past database schema versions, in *reverse* order
///
Expand All @@ -29,6 +29,7 @@ static KNOWN_VERSIONS: Lazy<Vec<KnownVersion>> = 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(120, "audit-log"),
KnownVersion::new(119, "tuf-artifact-key-uuid"),
KnownVersion::new(118, "support-bundles"),
KnownVersion::new(117, "add-completing-and-new-region-volume"),
Expand Down
Loading

0 comments on commit 1c4e5bf

Please sign in to comment.