Skip to content

Commit f9d36c0

Browse files
committed
initial audit log endpoints, data model, tests
1 parent ee095a6 commit f9d36c0

File tree

31 files changed

+1447
-53
lines changed

31 files changed

+1447
-53
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: 49 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;
@@ -421,6 +423,53 @@ impl<T: Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize>
421423
}
422424
}
423425

426+
/// Query parameters for pagination by timestamp and ID
427+
pub type PaginatedByTimeAndId<Selector = ()> = PaginationParams<
428+
ScanByTimeAndId<Selector>,
429+
PageSelectorByTimeAndId<Selector>,
430+
>;
431+
/// Page selector for pagination by timestamp and ID
432+
pub type PageSelectorByTimeAndId<Selector = ()> =
433+
PageSelector<ScanByTimeAndId<Selector>, (DateTime<Utc>, Uuid)>;
434+
435+
/// Scan parameters for resources that support scanning by (timestamp, id)
436+
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
437+
pub struct ScanByTimeAndId<Selector = ()> {
438+
#[serde(default = "default_ts_id_sort_mode")]
439+
sort_by: TimeAndIdSortMode,
440+
441+
#[serde(flatten)]
442+
pub selector: Selector,
443+
}
444+
/// Supported set of sort modes for scanning by timestamp and ID
445+
///
446+
/// Currently, we only support scanning in ascending order.
447+
#[derive(Copy, Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
448+
#[serde(rename_all = "snake_case")]
449+
pub enum TimeAndIdSortMode {
450+
/// sort in increasing order of timestamp and ID
451+
Ascending,
452+
}
453+
454+
fn default_ts_id_sort_mode() -> TimeAndIdSortMode {
455+
TimeAndIdSortMode::Ascending
456+
}
457+
458+
impl<T: Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize>
459+
ScanParams for ScanByTimeAndId<T>
460+
{
461+
type MarkerValue = (DateTime<Utc>, Uuid);
462+
fn direction(&self) -> PaginationOrder {
463+
PaginationOrder::Ascending
464+
}
465+
fn from_query(p: &PaginatedByTimeAndId<T>) -> Result<&Self, HttpError> {
466+
Ok(match p.page {
467+
WhichPage::First(ref scan_params) => scan_params,
468+
WhichPage::Next(PageSelector { ref scan, .. }) => scan,
469+
})
470+
}
471+
}
472+
424473
#[cfg(test)]
425474
mod test {
426475
use super::IdSortMode;

common/src/api/external/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,7 @@ pub enum ResourceType {
957957
AntiAffinityGroup,
958958
AntiAffinityGroupMember,
959959
AllowList,
960+
AuditLogEntry,
960961
BackgroundTask,
961962
BgpConfig,
962963
BgpAnnounceSet,

nexus/auth/src/authn/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ impl Context {
151151
&self.schemes_tried
152152
}
153153

154+
/// If the user is authenticated, return the last scheme in the list of
155+
/// schemes tried, which is the one that worked.
156+
pub fn scheme_used(&self) -> Option<&SchemeName> {
157+
match &self.kind {
158+
Kind::Authenticated(..) => self.schemes_tried().last(),
159+
Kind::Unauthenticated => None,
160+
}
161+
}
162+
154163
/// Returns an unauthenticated context for use internally
155164
pub fn internal_unauthenticated() -> Context {
156165
Context { kind: Kind::Unauthenticated, schemes_tried: vec![] }

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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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 std::net::IpAddr;
8+
9+
use crate::SqlU16;
10+
use crate::schema::{audit_log, audit_log_complete};
11+
use chrono::{DateTime, Utc};
12+
use diesel::prelude::*;
13+
use ipnetwork::IpNetwork;
14+
use nexus_types::external_api::views;
15+
use uuid::Uuid;
16+
17+
#[derive(Queryable, Insertable, Selectable, Clone, Debug)]
18+
#[diesel(table_name = audit_log)]
19+
pub struct AuditLogEntryInit {
20+
pub id: Uuid,
21+
pub timestamp: DateTime<Utc>,
22+
pub request_id: String,
23+
/// The API endpoint being logged, e.g., `project_create`
24+
pub request_uri: String,
25+
pub operation_id: String,
26+
pub source_ip: IpNetwork,
27+
// TODO: we probably want a dedicated enum for these columns and for that
28+
// we need a fancier set of columns. For example, we may want to initialize
29+
// the row with a _potential_ actor (probably a different field), like the
30+
// username or whatever is being used for login. This should probably be
31+
// preserved even after authentication determines an actual actor ID. See
32+
// the Actor struct in nexus/auth/src/authn/mod.ts
33+
34+
// these are optional because of requests like login attempts, where there
35+
// is no actor until after the operation.
36+
pub actor_id: Option<Uuid>,
37+
pub actor_silo_id: Option<Uuid>,
38+
39+
// TODO: fancier type for access method capturing possibility of login
40+
// attempts. might make sense to roll this all into the actor enum because
41+
// we have an access method if and only if we have an actor (I think)
42+
/// API token or session cookie. Optional because it will not be defined
43+
/// on unauthenticated requests like login attempts.
44+
pub access_method: Option<String>,
45+
}
46+
47+
// TODO: doc comments
48+
// TODO: figure out how this relates to the other struct. currently we're not
49+
// retrieving partial entries at all, but I think we will probably want to have
50+
// that capability
51+
#[derive(Queryable, Selectable, Clone, Debug)]
52+
#[diesel(table_name = audit_log_complete)]
53+
pub struct AuditLogEntry {
54+
pub id: Uuid,
55+
pub timestamp: DateTime<Utc>,
56+
pub request_id: String,
57+
pub request_uri: String,
58+
pub operation_id: String,
59+
pub source_ip: IpNetwork,
60+
pub actor_id: Option<Uuid>,
61+
pub actor_silo_id: Option<Uuid>,
62+
pub access_method: Option<String>,
63+
64+
// TODO: RFD 523 says: "Additionally, the response (or error) data should be
65+
// included in the same log entry as the original request data. Separating
66+
// the response from the request into two different log entries is extremely
67+
// expensive for customers to identify which requests correspond to which
68+
// responses." I guess the typical thing is to include a duration of the
69+
// request rather than a second timestamp.
70+
71+
// Seems like it has to be optional because at the beginning of the
72+
// operation, we have not yet resolved the resource selector to an ID
73+
pub resource_id: Option<Uuid>,
74+
75+
// Fields that are not present on init
76+
/// Time log entry was completed with info about result of operation
77+
pub time_completed: DateTime<Utc>,
78+
pub http_status_code: SqlU16,
79+
80+
// Error information if the action failed
81+
pub error_code: Option<String>,
82+
pub error_message: Option<String>,
83+
// TODO: including a real response complicates things
84+
// Response data on success (if applicable)
85+
// pub success_response: Option<Value>,
86+
}
87+
88+
impl AuditLogEntryInit {
89+
pub fn new(
90+
request_id: String,
91+
operation_id: String,
92+
request_uri: String,
93+
source_ip: IpAddr,
94+
actor_id: Option<Uuid>,
95+
actor_silo_id: Option<Uuid>,
96+
access_method: Option<String>,
97+
) -> Self {
98+
Self {
99+
id: Uuid::new_v4(),
100+
timestamp: Utc::now(),
101+
request_id,
102+
request_uri,
103+
operation_id,
104+
actor_id,
105+
actor_silo_id,
106+
source_ip: source_ip.into(),
107+
access_method,
108+
}
109+
}
110+
}
111+
112+
#[derive(AsChangeset, Clone)]
113+
#[diesel(table_name = audit_log)]
114+
pub struct AuditLogCompletion {
115+
pub time_completed: DateTime<Utc>,
116+
pub http_status_code: SqlU16,
117+
}
118+
119+
impl AuditLogCompletion {
120+
pub fn new(http_status_code: u16) -> Self {
121+
Self {
122+
time_completed: Utc::now(),
123+
http_status_code: SqlU16(http_status_code),
124+
}
125+
}
126+
}
127+
128+
// TODO: AuditLogActor
129+
// pub enum AuditLogActor {
130+
// UserBuiltin { user_builtin_id: Uuid },
131+
// TODO: include info about computed roles at runtime?
132+
// SiloUser { silo_user_id: Uuid, silo_id: Uuid },
133+
// Unauthenticated,
134+
// }
135+
136+
impl From<AuditLogEntry> for views::AuditLogEntry {
137+
fn from(entry: AuditLogEntry) -> Self {
138+
Self {
139+
id: entry.id,
140+
timestamp: entry.timestamp,
141+
request_id: entry.request_id,
142+
request_uri: entry.request_uri,
143+
operation_id: entry.operation_id,
144+
source_ip: entry.source_ip.ip(),
145+
resource_id: entry.resource_id,
146+
actor_id: entry.actor_id,
147+
actor_silo_id: entry.actor_silo_id,
148+
access_method: entry.access_method,
149+
time_completed: entry.time_completed,
150+
http_status_code: entry.http_status_code.0,
151+
error_code: entry.error_code,
152+
error_message: entry.error_message,
153+
}
154+
}
155+
}

nexus/db-model/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ extern crate newtype_derive;
1212
mod address_lot;
1313
mod affinity;
1414
mod allow_list;
15+
mod audit_log;
1516
mod bfd;
1617
mod bgp;
1718
mod block_size;
@@ -132,6 +133,7 @@ pub use self::unsigned::*;
132133
pub use address_lot::*;
133134
pub use affinity::*;
134135
pub use allow_list::*;
136+
pub use audit_log::*;
135137
pub use bfd::*;
136138
pub use bgp::*;
137139
pub use block_size::*;

0 commit comments

Comments
 (0)