Skip to content

Commit e1843f8

Browse files
committed
initial audit log endpoints, data model, tests
1 parent 119ba9c commit e1843f8

File tree

30 files changed

+1399
-53
lines changed

30 files changed

+1399
-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/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
@@ -431,6 +431,25 @@ has_relation(fleet: Fleet, "parent_fleet", ip_pool_list: IpPoolList)
431431
has_permission(actor: AuthenticatedActor, "create_child", ip_pool: IpPool)
432432
if silo in actor.silo and silo.fleet = ip_pool.fleet;
433433

434+
# Describes the policy for reading and writing the audit log
435+
resource AuditLog {
436+
permissions = [
437+
"list_children", # retrieve audit log
438+
"create_child", # create audit log entry
439+
];
440+
441+
relations = { parent_fleet: Fleet };
442+
443+
# Fleet viewers can read the audit log
444+
"list_children" if "viewer" on "parent_fleet";
445+
}
446+
# TODO: is this right? any op context should be able to write to the audit log?
447+
# feels weird though
448+
has_permission(_actor: AuthenticatedActor, "create_child", _audit_log: AuditLog);
449+
450+
has_relation(fleet: Fleet, "parent_fleet", audit_log: AuditLog)
451+
if audit_log.fleet = fleet;
452+
434453
# Describes the policy for creating and managing web console sessions.
435454
resource ConsoleSessionList {
436455
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;
@@ -133,6 +134,7 @@ pub use self::unsigned::*;
133134
pub use address_lot::*;
134135
pub use affinity::*;
135136
pub use allow_list::*;
137+
pub use audit_log::*;
136138
pub use bfd::*;
137139
pub use bgp::*;
138140
pub use block_size::*;

nexus/db-model/src/schema.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,3 +2164,42 @@ table! {
21642164
region_snapshot_snapshot_id -> Nullable<Uuid>,
21652165
}
21662166
}
2167+
2168+
table! {
2169+
audit_log (id) {
2170+
id -> Uuid,
2171+
timestamp -> Timestamptz,
2172+
request_id -> Text,
2173+
request_uri -> Text,
2174+
operation_id -> Text,
2175+
source_ip -> Inet,
2176+
resource_type -> Text,
2177+
actor_id -> Nullable<Uuid>,
2178+
actor_silo_id -> Nullable<Uuid>,
2179+
access_method -> Nullable<Text>,
2180+
resource_id -> Nullable<Uuid>,
2181+
time_completed -> Nullable<Timestamptz>,
2182+
http_status_code -> Nullable<Int4>, // SqlU16
2183+
error_code -> Nullable<Text>,
2184+
error_message -> Nullable<Text>
2185+
}
2186+
}
2187+
2188+
table! {
2189+
audit_log_complete (id) {
2190+
id -> Uuid,
2191+
timestamp -> Timestamptz,
2192+
request_id -> Text,
2193+
request_uri -> Text,
2194+
operation_id -> Text,
2195+
source_ip -> Inet,
2196+
actor_id -> Nullable<Uuid>,
2197+
actor_silo_id -> Nullable<Uuid>,
2198+
access_method -> Nullable<Text>,
2199+
resource_id -> Nullable<Uuid>,
2200+
time_completed -> Timestamptz,
2201+
http_status_code -> Int4, // SqlU16
2202+
error_code -> Nullable<Text>,
2203+
error_message -> Nullable<Text>
2204+
}
2205+
}

nexus/db-model/src/schema_versions.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock};
1616
///
1717
/// This must be updated when you change the database schema. Refer to
1818
/// schema/crdb/README.adoc in the root of this repository for details.
19-
pub const SCHEMA_VERSION: Version = Version::new(130, 0, 0);
19+
pub const SCHEMA_VERSION: Version = Version::new(131, 0, 0);
2020

2121
/// List of all past database schema versions, in *reverse* order
2222
///
@@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock<Vec<KnownVersion>> = LazyLock::new(|| {
2828
// | leaving the first copy as an example for the next person.
2929
// v
3030
// KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"),
31+
KnownVersion::new(131, "audit-log"),
3132
KnownVersion::new(130, "bp-sled-agent-generation"),
3233
KnownVersion::new(129, "create-target-release"),
3334
KnownVersion::new(128, "sled-resource-for-vmm"),

0 commit comments

Comments
 (0)