Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions common/src/api/external/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ use std::fmt::Result as FormatResult;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::num::{NonZeroU16, NonZeroU32};
use std::ops::Deref;
use std::str::FromStr;
use tufaceous_artifact::ArtifactHash;
use uuid::Uuid;
Expand Down Expand Up @@ -1008,6 +1009,7 @@ pub enum ResourceType {
ProjectImage,
Instance,
LoopbackAddress,
SiloAuthSettings,
SwitchPortSettings,
SupportBundle,
IpPool,
Expand Down Expand Up @@ -3277,6 +3279,68 @@ pub enum ImportExportPolicy {
Allow(Vec<oxnet::IpNet>),
}

/// Use instead of Option in API request body structs to get a field that can
/// be null (parsed as `None`) but is not optional. Unlike Option, Nullable
/// will fail to parse if the key is not present. The JSON Schema in the
/// OpenAPI definition will also reflect that the field is required. See
/// <https://github.com/serde-rs/serde/issues/2753>.
#[derive(Clone, Debug, Serialize)]
pub struct Nullable<T>(pub Option<T>);

impl<T> From<Option<T>> for Nullable<T> {
fn from(option: Option<T>) -> Self {
Nullable(option)
}
}

impl<T> Deref for Nullable<T> {
type Target = Option<T>;

fn deref(&self) -> &Self::Target {
&self.0
}
}

// it looks like we're just using Option's impl here, so why not derive instead?
// For some reason, deriving JsonSchema + #[serde(transparent)] doesn't work --
// it almost does, but the field does not end up marked required in the schema.
// There must be some special handling of Option somewhere causing it to be
// marked optional rather than nullable + required.

impl<T: JsonSchema> JsonSchema for Nullable<T> {
fn schema_name() -> String {
T::schema_name()
}

fn json_schema(
generator: &mut schemars::r#gen::SchemaGenerator,
) -> schemars::schema::Schema {
Option::<T>::json_schema(generator)
}

fn is_referenceable() -> bool {
Option::<T>::is_referenceable()
}
}

impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable<T> {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Self, D::Error> {
// This line is required to get a parse error on missing fields.
// It seems that when the field is missing in the JSON, struct
// deserialization produces an error before this function is even hit,
// and that error is passed in here inside `deserializer`. If we don't
// do this Value::deserialize to cause that error to be returned as a
// missing field error, Option's deserialize will eat it by turning it
// into a successful parse as None.
let value = serde_json::Value::deserialize(deserializer)?;

use serde::de::Error;
Option::<T>::deserialize(value).map_err(D::Error::custom).map(Nullable)
}
}

#[cfg(test)]
mod test {
use serde::Deserialize;
Expand Down
5 changes: 4 additions & 1 deletion nexus/db-model/src/device_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,12 @@ impl DeviceAccessToken {
device_code: String,
time_requested: DateTime<Utc>,
silo_user_id: Uuid,
time_expires: Option<DateTime<Utc>>,
) -> Self {
let now = Utc::now();
assert!(time_requested <= now);
assert!(time_expires.map_or(true, |t| t > now));

Self {
id: TypedUuid::new_v4().into(),
token: generate_token(),
Expand All @@ -147,7 +150,7 @@ impl DeviceAccessToken {
silo_user_id,
time_requested,
time_created: now,
time_expires: None,
time_expires,
}
}

Expand Down
2 changes: 2 additions & 0 deletions nexus/db-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ mod project;
mod rendezvous_debug_dataset;
mod semver_version;
mod serde_time_delta;
mod silo_auth_settings;
mod switch_interface;
mod switch_port;
mod target_release;
Expand Down Expand Up @@ -214,6 +215,7 @@ pub use schema_versions::*;
pub use semver_version::*;
pub use service_kind::*;
pub use silo::*;
pub use silo_auth_settings::*;
pub use silo_group::*;
pub use silo_user::*;
pub use silo_user_password_hash::*;
Expand Down
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 @@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock};
///
/// 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: Version = Version::new(145, 0, 0);
pub const SCHEMA_VERSION: Version = Version::new(146, 0, 0);

/// List of all past database schema versions, in *reverse* order
///
Expand All @@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock<Vec<KnownVersion>> = LazyLock::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(146, "silo-settings-token-expiration"),
KnownVersion::new(145, "token-and-session-ids"),
KnownVersion::new(144, "inventory-omicron-sled-config"),
KnownVersion::new(143, "alerts-renamening"),
Expand Down
68 changes: 68 additions & 0 deletions nexus/db-model/src/silo_auth_settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use chrono::{DateTime, Utc};
use nexus_db_schema::schema::silo_auth_settings;
use nexus_types::external_api::{params, views};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(
Queryable,
Insertable,
Debug,
Clone,
Selectable,
Serialize,
Deserialize,
AsChangeset,
)]
#[diesel(table_name = silo_auth_settings)]
pub struct SiloAuthSettings {
pub silo_id: Uuid,
pub time_created: DateTime<Utc>,
pub time_modified: DateTime<Utc>,

/// Max token lifetime in seconds. Null means no max: users can create
/// tokens that never expire.
pub device_token_max_ttl_seconds: Option<i64>,
}

impl SiloAuthSettings {
pub fn new(silo_id: Uuid) -> Self {
Self {
silo_id,
time_created: Utc::now(),
time_modified: Utc::now(),
device_token_max_ttl_seconds: None,
}
}
}

impl From<SiloAuthSettings> for views::SiloAuthSettings {
fn from(silo_auth_settings: SiloAuthSettings) -> Self {
Self {
silo_id: silo_auth_settings.silo_id,
device_token_max_ttl_seconds: silo_auth_settings
.device_token_max_ttl_seconds,
}
}
}

// Describes a set of updates for the [`SiloAuthSettings`] model.
#[derive(AsChangeset)]
#[diesel(table_name = silo_auth_settings)]
pub struct SiloAuthSettingsUpdate {
// Needs to be double Option so we can set a value of null in the DB by
// passing Some(None). None by itself is ignored by Diesel.
pub device_token_max_ttl_seconds: Option<Option<i64>>,
pub time_modified: DateTime<Utc>,
}

impl From<params::SiloAuthSettingsUpdate> for SiloAuthSettingsUpdate {
fn from(params: params::SiloAuthSettingsUpdate) -> Self {
Self {
device_token_max_ttl_seconds: Some(
params.device_token_max_ttl_seconds.map(|ttl| ttl.get().into()),
),
time_modified: Utc::now(),
}
}
}
1 change: 1 addition & 0 deletions nexus/db-queries/src/db/datastore/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ mod rendezvous_debug_dataset;
mod role;
mod saga;
mod silo;
mod silo_auth_settings;
mod silo_group;
mod silo_user;
mod sled;
Expand Down
28 changes: 22 additions & 6 deletions nexus/db-queries/src/db/datastore/silo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use nexus_db_fixed_data::silo::{DEFAULT_SILO, INTERNAL_SILO};
use nexus_db_lookup::DbConnection;
use nexus_db_model::Certificate;
use nexus_db_model::ServiceKind;
use nexus_db_model::SiloAuthSettings;
use nexus_db_model::SiloQuotas;
use nexus_types::external_api::params;
use nexus_types::external_api::shared;
Expand Down Expand Up @@ -64,24 +65,31 @@ impl DataStore {

debug!(opctx.log, "attempting to create built-in silos");

use nexus_db_schema::schema::silo::dsl;
use nexus_db_schema::schema::silo_quotas::dsl as quotas_dsl;
use nexus_db_schema::schema::silo;
use nexus_db_schema::schema::silo_auth_settings;
use nexus_db_schema::schema::silo_quotas;
let conn = self.pool_connection_authorized(opctx).await?;

let count = self
.transaction_retry_wrapper("load_builtin_silos")
.transaction(&conn, |conn| async move {
diesel::insert_into(quotas_dsl::silo_quotas)
diesel::insert_into(silo_quotas::table)
.values(SiloQuotas::arbitrarily_high_default(
DEFAULT_SILO.id(),
))
.on_conflict(quotas_dsl::silo_id)
.on_conflict(silo_quotas::silo_id)
.do_nothing()
.execute_async(&conn)
.await?;
diesel::insert_into(silo_auth_settings::table)
.values(SiloAuthSettings::new(DEFAULT_SILO.id()))
.on_conflict(silo_auth_settings::silo_id)
.do_nothing()
.execute_async(&conn)
.await?;
let count = diesel::insert_into(dsl::silo)
let count = diesel::insert_into(silo::table)
.values([&*DEFAULT_SILO, &*INTERNAL_SILO])
.on_conflict(dsl::id)
.on_conflict(silo::id)
.do_nothing()
.execute_async(&conn)
.await?;
Expand Down Expand Up @@ -300,6 +308,12 @@ impl DataStore {
),
)
.await?;
self.silo_auth_settings_create(
&conn,
&authz_silo,
SiloAuthSettings::new(authz_silo.id()),
)
.await?;

Ok::<Silo, TransactionError<Error>>(silo)
})
Expand Down Expand Up @@ -451,6 +465,8 @@ impl DataStore {
}

self.silo_quotas_delete(opctx, &conn, &authz_silo).await?;
self.silo_auth_settings_delete(opctx, &conn, &authz_silo)
.await?;

self.virtual_provisioning_collection_delete_on_connection(
&opctx.log, &conn, id,
Expand Down
114 changes: 114 additions & 0 deletions nexus/db-queries/src/db/datastore/silo_auth_settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use super::DataStore;
use crate::authz;
use crate::context::OpContext;
use async_bb8_diesel::AsyncRunQueryDsl;
use diesel::prelude::*;
use nexus_db_errors::ErrorHandler;
use nexus_db_errors::public_error_from_diesel;
use nexus_db_lookup::DbConnection;
use nexus_db_model::SiloAuthSettings;
use nexus_db_model::SiloAuthSettingsUpdate;
use omicron_common::api::external::DeleteResult;
use omicron_common::api::external::Error;
use omicron_common::api::external::ResourceType;
use omicron_common::api::external::UpdateResult;

// Directly modeled on settings query functions.

impl DataStore {
/// Creates new settings for a silo. This is grouped with silo creation
/// and shouldn't be called outside of that flow.
///
/// An authz check _cannot_ be performed here because the authz initialization
/// isn't complete and will lead to a db deadlock.
///
/// See <https://github.com/oxidecomputer/omicron/blob/07eb7dafc20e35e44edf429fcbb759cbb33edd5f/nexus/db-queries/src/db/datastore/rack.rs#L407-L410>
pub async fn silo_auth_settings_create(
&self,
conn: &async_bb8_diesel::Connection<DbConnection>,
authz_silo: &authz::Silo,
settings: SiloAuthSettings,
) -> Result<(), Error> {
let silo_id = authz_silo.id();
use nexus_db_schema::schema::silo_auth_settings;

diesel::insert_into(silo_auth_settings::table)
.values(settings)
.execute_async(conn)
.await
.map_err(|e| {
public_error_from_diesel(
e,
ErrorHandler::Conflict(
ResourceType::SiloAuthSettings,
&silo_id.to_string(),
),
)
})
.map(|_| ())
}

pub async fn silo_auth_settings_delete(
&self,
opctx: &OpContext,
conn: &async_bb8_diesel::Connection<DbConnection>,
authz_silo: &authz::Silo,
) -> DeleteResult {
// Given that the settings right now are somewhat of an extension of the
// Silo we just check for delete permission on the silo itself.
opctx.authorize(authz::Action::Delete, authz_silo).await?;

use nexus_db_schema::schema::silo_auth_settings;
diesel::delete(silo_auth_settings::table)
.filter(silo_auth_settings::silo_id.eq(authz_silo.id()))
.execute_async(conn)
.await
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?;

Ok(())
}

pub async fn silo_auth_settings_update(
&self,
opctx: &OpContext,
authz_silo: &authz::Silo,
updates: SiloAuthSettingsUpdate,
) -> UpdateResult<SiloAuthSettings> {
opctx.authorize(authz::Action::Modify, authz_silo).await?;
use nexus_db_schema::schema::silo_auth_settings::dsl;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, not important: seems like we ought to be able to import this globally in this module...

Copy link
Contributor Author

@david-crespo david-crespo Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For whatever reason we just do this in all the datastore functions. Kind of a cargo cult thing. The real reason to do it this way would be if if we imported * from the DSL like the examples on the Diesel site do.

let silo_id = authz_silo.id();
diesel::update(dsl::silo_auth_settings)
.filter(dsl::silo_id.eq(silo_id))
.set(updates)
.returning(SiloAuthSettings::as_returning())
.get_result_async(&*self.pool_connection_authorized(opctx).await?)
.await
.map_err(|e| {
public_error_from_diesel(
e,
ErrorHandler::Conflict(
ResourceType::SiloAuthSettings,
&silo_id.to_string(),
),
)
})
}

pub async fn silo_auth_settings_view(
&self,
opctx: &OpContext,
authz_silo: &authz::Silo,
) -> Result<SiloAuthSettings, Error> {
// Works for everyone when making a token because everyone can read
// their own silo. Operators looking at silo settings will have silo
// read on all silos.
opctx.authorize(authz::Action::Read, authz_silo).await?;

use nexus_db_schema::schema::silo_auth_settings::dsl;
dsl::silo_auth_settings
.filter(dsl::silo_id.eq(authz_silo.id()))
.first_async(&*self.pool_connection_authorized(opctx).await?)
.await
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
}
}
Loading
Loading