Skip to content

Commit 6f04417

Browse files
committed
initial audit log endpoints, data model, tests
1 parent d9a6a8b commit 6f04417

File tree

26 files changed

+1058
-6
lines changed

26 files changed

+1058
-6
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/src/api/external/http_pagination.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ use crate::api::external::Name;
4545
use crate::api::external::NameOrId;
4646
use crate::api::external::ObjectIdentity;
4747
use crate::api::external::PaginationOrder;
48+
use chrono::DateTime;
49+
use chrono::Utc;
4850
use dropshot::HttpError;
4951
use dropshot::PaginationParams;
5052
use dropshot::RequestContext;
@@ -409,6 +411,55 @@ impl<
409411
}
410412
}
411413

414+
// TODO: timestamp is not unique. does that mean we need to paginate by (timestamp, id)?
415+
416+
/// Query parameters for pagination by timestamp
417+
pub type PaginatedByTimestamp<Selector = ()> = PaginationParams<
418+
ScanByTimestamp<Selector>,
419+
PageSelectorByTimestamp<Selector>,
420+
>;
421+
/// Page selector for pagination by name only
422+
pub type PageSelectorByTimestamp<Selector = ()> =
423+
PageSelector<ScanByTimestamp<Selector>, DateTime<Utc>>;
424+
/// Scan parameters for resources that support scanning by name only
425+
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
426+
pub struct ScanByTimestamp<Selector = ()> {
427+
#[serde(default = "default_ts_sort_mode")]
428+
sort_by: TimestampSortMode,
429+
430+
#[serde(flatten)]
431+
pub selector: Selector,
432+
}
433+
/// Supported set of sort modes for scanning by timestamp only
434+
///
435+
/// Currently, we only support scanning in ascending order.
436+
#[derive(Copy, Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
437+
#[serde(rename_all = "snake_case")]
438+
pub enum TimestampSortMode {
439+
/// sort in increasing order of "name"
440+
Ascending,
441+
}
442+
443+
fn default_ts_sort_mode() -> TimestampSortMode {
444+
TimestampSortMode::Ascending
445+
}
446+
447+
impl<
448+
T: Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize,
449+
> ScanParams for ScanByTimestamp<T>
450+
{
451+
type MarkerValue = DateTime<Utc>;
452+
fn direction(&self) -> PaginationOrder {
453+
PaginationOrder::Ascending
454+
}
455+
fn from_query(p: &PaginatedByTimestamp<T>) -> Result<&Self, HttpError> {
456+
Ok(match p.page {
457+
WhichPage::First(ref scan_params) => scan_params,
458+
WhichPage::Next(PageSelector { ref scan, .. }) => scan,
459+
})
460+
}
461+
}
462+
412463
#[cfg(test)]
413464
mod test {
414465
use super::data_page_params_with_limit;

common/src/api/external/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,7 @@ pub enum ResourceType {
954954
AddressLot,
955955
AddressLotBlock,
956956
AllowList,
957+
AuditLogEntry,
957958
BackgroundTask,
958959
BgpConfig,
959960
BgpAnnounceSet,

nexus/auth/src/authz/api_resources.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,66 @@ impl AuthorizedResource for IpPoolList {
407407
roleset: &'fut mut RoleSet,
408408
) -> futures::future::BoxFuture<'fut, Result<(), Error>> {
409409
// There are no roles on the IpPoolList, only permissions. But we still
410-
// need to load the Fleet-related roles to verify that the actor has the
411-
// "admin" role on the Fleet (possibly conferred from a Silo role).
410+
// need to load the Fleet-related roles to verify that the actor's role
411+
// on the Fleet (possibly conferred from a Silo role).
412+
load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed()
413+
}
414+
415+
fn on_unauthorized(
416+
&self,
417+
_: &Authz,
418+
error: Error,
419+
_: AnyActor,
420+
_: Action,
421+
) -> Error {
422+
error
423+
}
424+
425+
fn polar_class(&self) -> oso::Class {
426+
Self::get_polar_class()
427+
}
428+
}
429+
430+
// Similar to IpPoolList, the audit log is a collection that doesn't exist in
431+
// the database as an entity distinct from its children (IP pools, or in this
432+
// case, audit log entries). We need a dummy resource here because we need
433+
// something to hang permissions off of. We need to be able to create audit log
434+
// children (entries) for login attempts, when there is no authenticated user,
435+
// as well as for normal requests with an authenticated user. For retrieval, we
436+
// want (to start out) to allow only fleet viewers to list children.
437+
438+
#[derive(Clone, Copy, Debug)]
439+
pub struct AuditLog;
440+
441+
/// Singleton representing the [`AuditLog`] for authz purposes
442+
pub const AUDIT_LOG: AuditLog = AuditLog;
443+
444+
impl Eq for AuditLog {}
445+
446+
impl PartialEq for AuditLog {
447+
fn eq(&self, _: &Self) -> bool {
448+
true
449+
}
450+
}
451+
452+
impl oso::PolarClass for AuditLog {
453+
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
454+
oso::Class::builder()
455+
.with_equality_check()
456+
.add_attribute_getter("fleet", |_: &AuditLog| FLEET)
457+
}
458+
}
459+
460+
impl AuthorizedResource for AuditLog {
461+
fn load_roles<'fut>(
462+
&'fut self,
463+
opctx: &'fut OpContext,
464+
authn: &'fut authn::Context,
465+
roleset: &'fut mut RoleSet,
466+
) -> futures::future::BoxFuture<'fut, Result<(), Error>> {
467+
// There are no roles on the AuditLog, only permissions. But we still
468+
// need to load the Fleet-related roles to verify that the actor's role
469+
// on the Fleet (possibly conferred from a Silo role).
412470
load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed()
413471
}
414472

nexus/auth/src/authz/omicron.polar

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,25 @@ has_relation(fleet: Fleet, "parent_fleet", ip_pool_list: IpPoolList)
417417
has_permission(actor: AuthenticatedActor, "create_child", ip_pool: IpPool)
418418
if silo in actor.silo and silo.fleet = ip_pool.fleet;
419419

420+
# Describes the policy for reading and writing the audit log
421+
resource AuditLog {
422+
permissions = [
423+
"list_children", # retrieve audit log
424+
"create_child", # create audit log entry
425+
];
426+
427+
relations = { parent_fleet: Fleet };
428+
429+
# Fleet viewers can read the audit log
430+
"list_children" if "viewer" on "parent_fleet";
431+
}
432+
# TODO: is this right? any op context should be able to write to the audit log?
433+
# feels weird though
434+
has_permission(_actor: AuthenticatedActor, "create_child", _audit_log: AuditLog);
435+
436+
has_relation(fleet: Fleet, "parent_fleet", audit_log: AuditLog)
437+
if audit_log.fleet = fleet;
438+
420439
# Describes the policy for creating and managing web console sessions.
421440
resource ConsoleSessionList {
422441
permissions = [ "create_child" ];

nexus/auth/src/authz/oso_generic.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<OsoInit, anyhow::Error> {
101101
let classes = [
102102
// Hand-written classes
103103
Action::get_polar_class(),
104+
AuditLog::get_polar_class(),
104105
AnyActor::get_polar_class(),
105106
AuthenticatedActor::get_polar_class(),
106107
BlueprintConfig::get_polar_class(),

nexus/db-model/src/audit_log.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/5.0/.
4+
5+
// Copyright 2025 Oxide Computer Company
6+
7+
use crate::schema::audit_log;
8+
use chrono::{DateTime, TimeDelta, Utc};
9+
use diesel::prelude::*;
10+
use nexus_types::external_api::views;
11+
use uuid::Uuid;
12+
13+
#[derive(Queryable, Insertable, Selectable, Clone, Debug)]
14+
#[diesel(table_name = audit_log)]
15+
pub struct AuditLogEntry {
16+
pub id: Uuid,
17+
pub timestamp: DateTime<Utc>,
18+
pub request_id: String,
19+
// TODO: this isn't in the RFD but it seems nice to have
20+
pub request_uri: String,
21+
pub operation_id: String,
22+
pub source_ip: String,
23+
pub resource_type: String,
24+
25+
// TODO: we probably want a dedicated enum for these columns and for that
26+
// we need a fancier set of columns. For example, we may want to initialize
27+
// the row with a _potential_ actor (probably a different field), like the
28+
// username or whatever is being used for login. This should probably be
29+
// preserved even after authentication determines an actual actor ID. See
30+
// the Actor struct in nexus/auth/src/authn/mod.ts
31+
32+
// these are optional because of requests like login attempts, where there
33+
// is no actor until after the operation.
34+
pub actor_id: Option<Uuid>,
35+
pub actor_silo_id: Option<Uuid>,
36+
37+
/// The specific action that was attempted (create, delete, update, etc)
38+
pub action: String, // TODO: enum type?
39+
40+
// TODO: we will need to add headers in the client to get this info
41+
// How the actor authenticated (api_key, console, etc)
42+
// pub access_method: String,
43+
44+
// TODO: RFD 523 says: "Additionally, the response (or error) data should be
45+
// included in the same log entry as the original request data. Separating
46+
// the response from the request into two different log entries is extremely
47+
// expensive for customers to identify which requests correspond to which
48+
// responses." I guess the typical thing is to include a duration of the
49+
// request rather than a second timestamp.
50+
51+
// Seems like it has to be optional because at the beginning of the
52+
// operation, we have not yet resolved the resource selector to an ID
53+
pub resource_id: Option<Uuid>,
54+
55+
// Fields that are optional because they get filled in after the action completes
56+
/// Time in milliseconds between receiving request and responding
57+
pub duration: Option<TimeDelta>,
58+
59+
// Error information if the action failed
60+
pub error_code: Option<String>,
61+
pub error_message: Option<String>,
62+
// TODO: including a real response complicates things
63+
// Response data on success (if applicable)
64+
// pub success_response: Option<Value>,
65+
}
66+
67+
impl AuditLogEntry {
68+
pub fn new(
69+
request_id: String,
70+
operation_id: String,
71+
request_uri: String,
72+
actor_id: Option<Uuid>,
73+
actor_silo_id: Option<Uuid>,
74+
) -> Self {
75+
Self {
76+
id: Uuid::new_v4(),
77+
timestamp: Utc::now(),
78+
request_id,
79+
request_uri,
80+
operation_id,
81+
actor_id,
82+
actor_silo_id,
83+
84+
// TODO: actually get all these values
85+
source_ip: String::new(),
86+
resource_type: String::new(),
87+
action: String::new(),
88+
89+
// fields that can only be filled in after the operation
90+
resource_id: None,
91+
duration: None,
92+
error_code: None,
93+
error_message: None,
94+
}
95+
}
96+
}
97+
98+
// TODO: Add a struct representing only the fields set at log entry init time,
99+
// use as an arg to the datastore init function to make misuse harder
100+
101+
// TODO: AuditLogActor
102+
// pub enum AuditLogActor {
103+
// UserBuiltin { user_builtin_id: Uuid },
104+
// TODO: include info about computed roles at runtime?
105+
// SiloUser { silo_user_id: Uuid, silo_id: Uuid },
106+
// Unauthenticated,
107+
// }
108+
109+
impl From<AuditLogEntry> for views::AuditLogEntry {
110+
fn from(entry: AuditLogEntry) -> Self {
111+
Self {
112+
id: entry.id,
113+
timestamp: entry.timestamp,
114+
request_id: entry.request_id,
115+
request_uri: entry.request_uri,
116+
operation_id: entry.operation_id,
117+
source_ip: entry.source_ip,
118+
resource_type: entry.resource_type,
119+
resource_id: entry.resource_id,
120+
actor_id: entry.actor_id,
121+
actor_silo_id: entry.actor_silo_id,
122+
action: entry.action,
123+
duration_ms: entry.duration.map(|d| d.num_milliseconds()),
124+
error_code: entry.error_code,
125+
error_message: entry.error_message,
126+
}
127+
}
128+
}

nexus/db-model/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extern crate newtype_derive;
1111

1212
mod address_lot;
1313
mod allow_list;
14+
mod audit_log;
1415
mod bfd;
1516
mod bgp;
1617
mod block_size;
@@ -130,6 +131,7 @@ pub use self::macaddr::*;
130131
pub use self::unsigned::*;
131132
pub use address_lot::*;
132133
pub use allow_list::*;
134+
pub use audit_log::*;
133135
pub use bfd::*;
134136
pub use bgp::*;
135137
pub use block_size::*;

nexus/db-model/src/schema.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2124,3 +2124,22 @@ table! {
21242124
region_snapshot_snapshot_id -> Nullable<Uuid>,
21252125
}
21262126
}
2127+
2128+
table! {
2129+
audit_log (id) {
2130+
id -> Uuid,
2131+
timestamp -> Timestamptz,
2132+
request_id -> Text,
2133+
request_uri -> Text,
2134+
operation_id -> Text,
2135+
source_ip -> Text,
2136+
resource_type -> Text,
2137+
actor_id -> Nullable<Uuid>,
2138+
actor_silo_id -> Nullable<Uuid>,
2139+
action -> Text,
2140+
resource_id -> Nullable<Uuid>,
2141+
duration -> Nullable<Interval>,
2142+
error_code -> Nullable<Text>,
2143+
error_message -> Nullable<Text>
2144+
}
2145+
}

nexus/db-model/src/schema_versions.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use std::collections::BTreeMap;
1717
///
1818
/// This must be updated when you change the database schema. Refer to
1919
/// schema/crdb/README.adoc in the root of this repository for details.
20-
pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(118, 0, 0);
20+
pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(119, 0, 0);
2121

2222
/// List of all past database schema versions, in *reverse* order
2323
///
@@ -29,6 +29,7 @@ static KNOWN_VERSIONS: Lazy<Vec<KnownVersion>> = Lazy::new(|| {
2929
// | leaving the first copy as an example for the next person.
3030
// v
3131
// KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"),
32+
KnownVersion::new(119, "audit-log"),
3233
KnownVersion::new(118, "support-bundles"),
3334
KnownVersion::new(117, "add-completing-and-new-region-volume"),
3435
KnownVersion::new(116, "bp-physical-disk-disposition"),

0 commit comments

Comments
 (0)