Skip to content

Commit 9258b89

Browse files
committed
initial audit log endpoints, data model, tests
1 parent 1c92838 commit 9258b89

File tree

30 files changed

+1087
-6
lines changed

30 files changed

+1087
-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: 52 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,56 @@ impl<
409411
}
410412
}
411413

414+
/// Query parameters for pagination by timestamp and ID
415+
pub type PaginatedByTimestampAndId<Selector = ()> = PaginationParams<
416+
ScanByTimestampAndId<Selector>,
417+
PageSelectorByTimestampAndId<Selector>,
418+
>;
419+
/// Page selector for pagination by name only
420+
pub type PageSelectorByTimestampAndId<Selector = ()> =
421+
PageSelector<ScanByTimestampAndId<Selector>, (DateTime<Utc>, Uuid)>;
422+
423+
/// Scan parameters for resources that support scanning by (timestamp, id)
424+
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
425+
pub struct ScanByTimestampAndId<Selector = ()> {
426+
#[serde(default = "default_ts_id_sort_mode")]
427+
sort_by: TimestampAndIdSortMode,
428+
429+
#[serde(flatten)]
430+
pub selector: Selector,
431+
}
432+
/// Supported set of sort modes for scanning by timestamp and ID
433+
///
434+
/// Currently, we only support scanning in ascending order.
435+
#[derive(Copy, Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
436+
#[serde(rename_all = "snake_case")]
437+
pub enum TimestampAndIdSortMode {
438+
/// sort in increasing order of "name"
439+
Ascending,
440+
}
441+
442+
fn default_ts_id_sort_mode() -> TimestampAndIdSortMode {
443+
TimestampAndIdSortMode::Ascending
444+
}
445+
446+
impl<
447+
T: Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize,
448+
> ScanParams for ScanByTimestampAndId<T>
449+
{
450+
type MarkerValue = (DateTime<Utc>, Uuid);
451+
fn direction(&self) -> PaginationOrder {
452+
PaginationOrder::Ascending
453+
}
454+
fn from_query(
455+
p: &PaginatedByTimestampAndId<T>,
456+
) -> Result<&Self, HttpError> {
457+
Ok(match p.page {
458+
WhichPage::First(ref scan_params) => scan_params,
459+
WhichPage::Next(PageSelector { ref scan, .. }) => scan,
460+
})
461+
}
462+
}
463+
412464
#[cfg(test)]
413465
mod test {
414466
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
@@ -406,8 +406,66 @@ impl AuthorizedResource for IpPoolList {
406406
roleset: &'fut mut RoleSet,
407407
) -> futures::future::BoxFuture<'fut, Result<(), Error>> {
408408
// There are no roles on the IpPoolList, only permissions. But we still
409-
// need to load the Fleet-related roles to verify that the actor has the
410-
// "admin" role on the Fleet (possibly conferred from a Silo role).
409+
// need to load the Fleet-related roles to verify that the actor's role
410+
// on the Fleet (possibly conferred from a Silo role).
411+
load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed()
412+
}
413+
414+
fn on_unauthorized(
415+
&self,
416+
_: &Authz,
417+
error: Error,
418+
_: AnyActor,
419+
_: Action,
420+
) -> Error {
421+
error
422+
}
423+
424+
fn polar_class(&self) -> oso::Class {
425+
Self::get_polar_class()
426+
}
427+
}
428+
429+
// Similar to IpPoolList, the audit log is a collection that doesn't exist in
430+
// the database as an entity distinct from its children (IP pools, or in this
431+
// case, audit log entries). We need a dummy resource here because we need
432+
// something to hang permissions off of. We need to be able to create audit log
433+
// children (entries) for login attempts, when there is no authenticated user,
434+
// as well as for normal requests with an authenticated user. For retrieval, we
435+
// want (to start out) to allow only fleet viewers to list children.
436+
437+
#[derive(Clone, Copy, Debug)]
438+
pub struct AuditLog;
439+
440+
/// Singleton representing the [`AuditLog`] for authz purposes
441+
pub const AUDIT_LOG: AuditLog = AuditLog;
442+
443+
impl Eq for AuditLog {}
444+
445+
impl PartialEq for AuditLog {
446+
fn eq(&self, _: &Self) -> bool {
447+
true
448+
}
449+
}
450+
451+
impl oso::PolarClass for AuditLog {
452+
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
453+
oso::Class::builder()
454+
.with_equality_check()
455+
.add_attribute_getter("fleet", |_: &AuditLog| FLEET)
456+
}
457+
}
458+
459+
impl AuthorizedResource for AuditLog {
460+
fn load_roles<'fut>(
461+
&'fut self,
462+
opctx: &'fut OpContext,
463+
authn: &'fut authn::Context,
464+
roleset: &'fut mut RoleSet,
465+
) -> futures::future::BoxFuture<'fut, Result<(), Error>> {
466+
// There are no roles on the AuditLog, only permissions. But we still
467+
// need to load the Fleet-related roles to verify that the actor's role
468+
// on the Fleet (possibly conferred from a Silo role).
411469
load_roles_for_resource_tree(&FLEET, opctx, authn, roleset).boxed()
412470
}
413471

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
@@ -2122,3 +2122,22 @@ table! {
21222122
region_snapshot_snapshot_id -> Nullable<Uuid>,
21232123
}
21242124
}
2125+
2126+
table! {
2127+
audit_log (id) {
2128+
id -> Uuid,
2129+
timestamp -> Timestamptz,
2130+
request_id -> Text,
2131+
request_uri -> Text,
2132+
operation_id -> Text,
2133+
source_ip -> Text,
2134+
resource_type -> Text,
2135+
actor_id -> Nullable<Uuid>,
2136+
actor_silo_id -> Nullable<Uuid>,
2137+
action -> Text,
2138+
resource_id -> Nullable<Uuid>,
2139+
duration -> Nullable<Interval>,
2140+
error_code -> Nullable<Text>,
2141+
error_message -> Nullable<Text>
2142+
}
2143+
}

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(119, 0, 0);
20+
pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(120, 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(120, "audit-log"),
3233
KnownVersion::new(119, "tuf-artifact-key-uuid"),
3334
KnownVersion::new(118, "support-bundles"),
3435
KnownVersion::new(117, "add-completing-and-new-region-volume"),

0 commit comments

Comments
 (0)