diff --git a/Cargo.lock b/Cargo.lock index 48065a2e..992965b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2811,6 +2811,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -3519,6 +3525,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/v-api/Cargo.toml b/v-api/Cargo.toml index 7c843b45..25b618b3 100644 --- a/v-api/Cargo.toml +++ b/v-api/Cargo.toml @@ -46,7 +46,7 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tracing = { workspace = true } url = { workspace = true } -uuid = { workspace = true, features = ["v4", "serde"] } +uuid = { workspace = true, features = ["v4", "v5", "serde"] } v-api-param = { path = "../v-api-param" } v-api-permission-derive = { path = "../v-api-permission-derive" } v-model = { path = "../v-model" } diff --git a/v-api/src/context/mapping.rs b/v-api/src/context/mapping.rs index 91a898fe..3b0d7ba1 100644 --- a/v-api/src/context/mapping.rs +++ b/v-api/src/context/mapping.rs @@ -6,9 +6,9 @@ use newtype_uuid::TypedUuid; use serde_json::Value; use std::{collections::BTreeSet, sync::Arc}; use v_model::{ - AccessGroupId, Mapper, MapperId, NewMapper, Permissions, + AccessGroupId, Mapper, MapperId, NewMapper, NewMapperEvent, Permissions, UserId, permissions::Caller, - storage::{ListPagination, MapperFilter, MapperStore, StoreError}, + storage::{ListPagination, MapperEventStore, MapperFilter, MapperStore, StoreError}, }; use crate::{ @@ -22,6 +22,7 @@ use crate::{ pub struct MappingContext { engine: Option>>, storage: Arc>, + ephemeral_mappers: Vec, } impl MappingContext @@ -32,6 +33,7 @@ where Self { engine: None, storage, + ephemeral_mappers: Vec::new(), } } @@ -48,6 +50,14 @@ where previous } + pub fn set_ephemeral_mappers(&mut self, mappers: Vec) { + self.ephemeral_mappers = mappers; + } + + pub fn is_ephemeral(&self, id: &TypedUuid) -> bool { + self.ephemeral_mappers.iter().any(|m| &m.id == id) + } + pub fn validate(&self, value: &Value) -> bool { match &self.engine { Some(engine) => engine.validate_mapping_data(value), @@ -61,12 +71,15 @@ where included_depleted: bool, ) -> ResourceResult, StoreError> { if caller.can(&VPermission::GetMappersAll.into()) { - Ok(MapperStore::list( + let mut mappers = MapperStore::list( &*self.storage, MapperFilter::default().depleted(included_depleted), &ListPagination::unlimited(), ) - .await?) + .await?; + mappers.extend(self.ephemeral_mappers.iter().cloned()); + + Ok(mappers) } else { resource_restricted() } @@ -100,6 +113,7 @@ where &self, caller: &Caller, info: &UserInfo, + user_id: TypedUuid, ) -> ResourceResult<(Permissions, BTreeSet>), StoreError> { let mut mapped_permissions = Permissions::new(); let mut mapped_groups = BTreeSet::new(); @@ -111,10 +125,10 @@ where // instead handle mappers that become depleted before we can evaluate them at evaluation // time. for mapper in self.get_mappers(caller, false).await? { - tracing::trace!(?mapper.name, "Attempt to run mapper"); + let is_ephemeral = self.is_ephemeral(&mapper.id); + tracing::trace!(?mapper.name, is_ephemeral, "Attempt to run mapper"); // Try to transform this mapper into a mapping - // let mappings = self.mapping_fns.iter().filter_map(|mapping_fn| mapping_fn(mapper.clone()).ok()).nth(0); let mapping = engine.create_mapping(mapper.clone()); let (mut permissions, mut groups) = match mapping { @@ -127,31 +141,31 @@ where } Err(err) => { // Errors here can be expected. They are reported, but not acted upon - tracing::info!(?err, "Not mapping was found for mapper"); + tracing::info!(?err, "No mapping was found for mapper"); (Permissions::new(), BTreeSet::default()) } }; - // If a rule is set to apply a permission or group to a user, then the rule needs to be - // checked for usage. If it does not have an activation limit then nothing is needed. - // If it does have a limit then we need to attempt to consume an activation. If the - // consumption works then we add the permissions. If they fail then we do not, but we - // do not fail the entire mapping process let apply = if !permissions.is_empty() || !groups.is_empty() { - if mapper.max_activations.is_some() { + if is_ephemeral { + // Ephemeral mappers always apply - no activation gating + true + } else if mapper.max_activations.is_some() { + // Dynamic mappers with activation limits need to consume an activation match self.consume_mapping_activation(&mapper).await { Ok(_) => true, Err(err) => { // TODO: Inspect the error. We expect to see a conflict error, and // should is expected to be seen. Other errors are problematic. - tracing::warn!( + tracing::info!( ?err, - "Login may have attempted to use depleted mapper. This may be ok if it is an isolated occurrence, but should occur repeatedly." + "Login may have attempted to use depleted mapper." ); false } } } else { + // Dynamic mappers without activation limits always apply true } } else { @@ -159,6 +173,19 @@ where }; if apply { + // Record the mapper event for audit purposes. + // TODO: This should hook into an audit log feature + if let Err(err) = self + .record_mapper_event(&mapper, user_id, is_ephemeral) + .await + { + tracing::warn!( + ?err, + mapper_name = ?mapper.name, + "Failed to record mapper event" + ); + } + mapped_permissions.append(&mut permissions); mapped_groups.append(&mut groups); } @@ -182,4 +209,24 @@ where .await .map(|_| ()) } + + async fn record_mapper_event( + &self, + mapper: &Mapper, + user_id: TypedUuid, + ephemeral: bool, + ) -> Result<(), StoreError> { + let event = NewMapperEvent { + id: TypedUuid::new_v4(), + mapper_id: mapper.id, + mapper_name: mapper.name.clone(), + user_id, + rule: mapper.rule.clone(), + ephemeral, + }; + + MapperEventStore::record(&*self.storage, &event) + .await + .map(|_| ()) + } } diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index 79e24ec0..8fc086ca 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -8,8 +8,9 @@ use chrono::{TimeDelta, Utc}; use dropshot::{ClientErrorStatusCode, HttpError, RequestContext, ServerContext}; use futures::future::join_all; use jsonwebtoken::jwk::JwkSet; -use newtype_uuid::TypedUuid; -use serde::Serialize; +use newtype_uuid::{GenericUuid, TypedUuid}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; #[cfg(feature = "sagas")] use slog::Logger; use std::{fmt::Debug, future::Future, path::PathBuf, sync::Arc}; @@ -23,15 +24,15 @@ use v_model::saga::{ view::SagaExecNodeId, }; use v_model::{ - AccessGroupId, ApiUserInfo, ApiUserProvider, LinkRequest, NewApiUser, NewApiUserProvider, - NewLinkRequest, UserId, UserProviderId, + AccessGroupId, ApiUserInfo, ApiUserProvider, LinkRequest, Mapper, NewApiUser, + NewApiUserProvider, NewLinkRequest, UserId, UserProviderId, permissions::{Caller, Permission}, storage::{ AccessGroupStore, AccessTokenStore, ApiKeyStore, ApiUserContactEmailStore, ApiUserFilter, ApiUserProviderFilter, ApiUserProviderStore, ApiUserStore, LinkRequestStore, ListPagination, LoginAttemptStore, MagicLinkAttemptStore, MagicLinkRedirectUriStore, - MagicLinkSecretStore, MagicLinkStore, MapperStore, OAuthClientRedirectUriStore, - OAuthClientSecretStore, OAuthClientStore, StoreError, + MagicLinkSecretStore, MagicLinkStore, MapperEventStore, MapperStore, + OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, StoreError, postgres::{PostgresError, PostgresStore}, }, }; @@ -90,6 +91,7 @@ pub trait VApiStorage: + OAuthClientRedirectUriStore + AccessGroupStore

+ MapperStore + + MapperEventStore + LinkRequestStore + MagicLinkStore + MagicLinkSecretStore @@ -117,6 +119,7 @@ where + OAuthClientRedirectUriStore + AccessGroupStore

+ MapperStore + + MapperEventStore + LinkRequestStore + MagicLinkStore + MagicLinkSecretStore @@ -143,6 +146,7 @@ pub trait VApiStorage: + OAuthClientRedirectUriStore + AccessGroupStore

+ MapperStore + + MapperEventStore + LinkRequestStore + MagicLinkStore + MagicLinkSecretStore @@ -168,6 +172,7 @@ where + OAuthClientRedirectUriStore + AccessGroupStore

+ MapperStore + + MapperEventStore + LinkRequestStore + MagicLinkStore + MagicLinkSecretStore @@ -485,122 +490,121 @@ where .await .inner_err_into()?; + // Determine the user_id upfront so we can pass it to + // get_mapped_fields for event recording. For new users we + // pre-generate the id; for existing users we use the known id. + let (user_id, is_new_user) = match api_user_providers.len() { + 0 => (TypedUuid::new_v4(), true), + 1 => (api_user_providers[0].user_id, false), + _ => { + tracing::error!( + count = api_user_providers.len(), + "Found multiple providers for external id" + ); + + return resource_error(ApiError::from(StoreError::InvariantFailed( + "Multiple providers for external id found".to_string(), + ))); + } + }; + let (mapped_permissions, mapped_groups) = self .mapping - .get_mapped_fields(caller, &info) + .get_mapped_fields(caller, &info, user_id) .await .inner_err_into()?; tracing::debug!(?mapped_permissions, "Computed mapping permissions"); tracing::debug!(?mapped_groups, "Computed mapped groups"); - match api_user_providers.len() { - 0 => { - tracing::info!( - ?mapped_permissions, - ?mapped_groups, - "Did not find any existing users. Registering a new user." - ); - - // Resolve the full groups so that create_api_user can verify - // the caller is allowed to grant the permissions they carry. - let groups = self - .group - .list_groups( - caller, - v_model::storage::AccessGroupFilter { - id: Some(mapped_groups.into_iter().collect()), - ..Default::default() - }, - ) - .await - .inner_err_into()?; - - let user = self - .user - .create_api_user(caller, mapped_permissions, groups) - .await - .inner_err_into()?; + if is_new_user { + tracing::info!( + ?mapped_permissions, + ?mapped_groups, + "Did not find any existing users. Registering a new user." + ); + + // Resolve the full groups so that create_api_user can verify + // the caller is allowed to grant the permissions they carry. + let groups = self + .group + .list_groups( + caller, + v_model::storage::AccessGroupFilter { + id: Some(mapped_groups.into_iter().collect()), + ..Default::default() + }, + ) + .await + .inner_err_into()?; - let user_provider = self - .user - .update_api_user_provider( - caller, - NewApiUserProvider { - id: TypedUuid::new_v4(), - user_id: user.user.id, - emails: info.verified_emails, - provider: info.external_id.provider().to_string(), - provider_id: info.external_id.id().to_string(), - display_names: info - .display_name - .map(|name| vec![name]) - .unwrap_or_default(), - }, - ) - .await - .inner_err_into()?; + let user = self + .user + .create_api_user(caller, user_id, mapped_permissions, groups) + .await + .inner_err_into()?; - Ok((user, user_provider)) - } - 1 => { - tracing::info!( - "Found an existing user provider. Ensuring mapped permissions and groups for user." - ); + let user_provider = self + .user + .update_api_user_provider( + caller, + NewApiUserProvider { + id: TypedUuid::new_v4(), + user_id: user.user.id, + emails: info.verified_emails, + provider: info.external_id.provider().to_string(), + provider_id: info.external_id.id().to_string(), + display_names: info.display_name.map(|name| vec![name]).unwrap_or_default(), + }, + ) + .await + .inner_err_into()?; - // This branch ensures that there is a 0th indexed item - let mut provider = api_user_providers.into_iter().nth(0).unwrap(); + Ok((user, user_provider)) + } else { + tracing::info!( + "Found an existing user provider. Ensuring mapped permissions and groups for user." + ); - // Update the provider with the newest user info - provider.emails = info.verified_emails; - provider.display_names = - info.display_name.map(|name| vec![name]).unwrap_or_default(); + // This branch ensures that there is a 0th indexed item + let mut provider = api_user_providers.into_iter().nth(0).unwrap(); - tracing::info!(?provider.id, "Updating provider for user"); + // Update the provider with the newest user info + provider.emails = info.verified_emails; + provider.display_names = info.display_name.map(|name| vec![name]).unwrap_or_default(); - let provider = self - .user - .update_api_user_provider(caller, provider.clone().into()) - .await - .inner_err_into()?; + tracing::info!(?provider.id, "Updating provider for user"); - tracing::info!(?provider.id, ?provider.user_id, "Updating found user permissions and groups"); + let provider = self + .user + .update_api_user_provider(caller, provider.clone().into()) + .await + .inner_err_into()?; - // Add mapped permissions to the existing user - self.user - .add_permissions_to_user(caller, &provider.user_id, mapped_permissions) - .await - .inner_err_into()?; + tracing::info!(?provider.id, ?provider.user_id, "Updating found user permissions and groups"); - // Add mapped groups to the existing user - for group_id in &mapped_groups { - self.add_api_user_to_group(caller, &provider.user_id, group_id) - .await - .inner_err_into()?; - } + // Add mapped permissions to the existing user + self.user + .add_permissions_to_user(caller, &provider.user_id, mapped_permissions) + .await + .inner_err_into()?; - let updated_user = self - .user - .get_api_user(caller, &provider.user_id) + // Add mapped groups to the existing user + for group_id in &mapped_groups { + self.add_api_user_to_group(caller, &provider.user_id, group_id) .await .inner_err_into()?; + } - tracing::info!(?updated_user.user.id, "Updated user permissions and groups"); + let updated_user = self + .user + .get_api_user(caller, &provider.user_id) + .await + .inner_err_into()?; - Ok((updated_user, provider)) - } - _ => { - // If we found more than one provider, then we have encountered an inconsistency in - // our database. - tracing::error!( - count = api_user_providers.len(), - "Found multiple providers for external id" - ); + tracing::info!(?updated_user.user.id, "Updated user permissions and groups"); - resource_error(ApiError::from(StoreError::InvariantFailed( - "Multiple providers for external id found".to_string(), - ))) - } + Ok((updated_user, provider)) } } @@ -741,6 +745,19 @@ where } } +/// Configuration for an ephemeral mapper that is loaded from service configuration. +/// +/// Ephemeral mappers exist only in memory for the lifetime of the process. They cannot +/// be modified or deleted via the API. They do not support activation limits, they +/// fire unconditionally whenever their rule matches. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EphemeralMapperConfig { + /// Human-readable name for this mapper + pub name: String, + /// The mapping rule as a JSON value (same format as dynamic mapper rules) + pub rule: Value, +} + #[derive(Debug, Error)] pub enum VContextBuilderError { #[error("Conflicting configuration, only one of {0} and {1} can be set")] @@ -761,6 +778,7 @@ pub struct VContextBuilder { storage: Option>>, storage_url: Option, keys: Option>, + mappers: Vec, #[cfg(feature = "sagas")] saga: Option<(TypedUuid, Option)>, } @@ -787,6 +805,7 @@ where storage: None, storage_url: None, keys: None, + mappers: Vec::new(), #[cfg(feature = "sagas")] saga: None, } @@ -827,6 +846,11 @@ where self } + pub fn with_mappers(mut self, mappers: Vec) -> Self { + self.mappers = mappers; + self + } + #[cfg(feature = "sagas")] pub fn with_saga_backend( mut self, @@ -910,6 +934,38 @@ where group_ctx.clone(), )))); + // Convert ephemeral mapper configs into Mapper structs with deterministic IDs + let ephemeral_mappers: Vec = self + .mappers + .into_iter() + .map(|config| { + // Generate a deterministic UUID v5 from the mapper name so that + // IDs are stable across process restarts + let id = Uuid::new_v5(&Uuid::NAMESPACE_URL, config.name.as_bytes()); + Mapper { + id: TypedUuid::from_untyped_uuid(id), + name: config.name, + rule: config.rule, + activations: None, + max_activations: None, + ephemeral: true, + depleted_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + deleted_at: None, + } + }) + .collect(); + + if !ephemeral_mappers.is_empty() { + tracing::info!( + count = ephemeral_mappers.len(), + "Loaded ephemeral mappers from configuration" + ); + } + + mapping_ctx.set_ephemeral_mappers(ephemeral_mappers); + #[cfg(feature = "sagas")] let saga = if let Some((node_id, logger)) = self.saga { SagaContext::new(node_id, storage.clone(), logger) @@ -1233,14 +1289,14 @@ pub(crate) mod test_mocks { AccessGroupStore, AccessTokenStore, ApiKeyStore, ApiUserContactEmailStore, ApiUserProviderStore, ApiUserStore, LinkRequestStore, ListPagination, LoginAttemptStore, MagicLinkAttemptFilter, MagicLinkAttemptStore, MagicLinkFilter, - MagicLinkRedirectUriStore, MagicLinkSecretStore, MagicLinkStore, MapperStore, - MockAccessGroupStore, MockAccessTokenStore, MockApiKeyStore, + MagicLinkRedirectUriStore, MagicLinkSecretStore, MagicLinkStore, MapperEventStore, + MapperStore, MockAccessGroupStore, MockAccessTokenStore, MockApiKeyStore, MockApiUserContactEmailStore, MockApiUserProviderStore, MockApiUserStore, MockLinkRequestStore, MockLoginAttemptStore, MockMagicLinkAttemptStore, MockMagicLinkRedirectUriStore, MockMagicLinkSecretStore, MockMagicLinkStore, - MockMapperStore, MockOAuthClientRedirectUriStore, MockOAuthClientSecretStore, - MockOAuthClientStore, OAuthClientRedirectUriStore, OAuthClientSecretStore, - OAuthClientStore, StoreError, + MockMapperEventStore, MockMapperStore, MockOAuthClientRedirectUriStore, + MockOAuthClientSecretStore, MockOAuthClientStore, OAuthClientRedirectUriStore, + OAuthClientSecretStore, OAuthClientStore, StoreError, }, }; @@ -1302,6 +1358,7 @@ pub(crate) mod test_mocks { pub oauth_client_redirect_uri_store: Option>, pub access_group_store: Option>>, pub mapper_store: Option>, + pub mapper_event_store: Option>, pub link_request_store: Option>, pub magic_link_store: Option>, pub magic_link_secret_store: Option>, @@ -1327,6 +1384,7 @@ pub(crate) mod test_mocks { oauth_client_redirect_uri_store: None, access_group_store: None, mapper_store: None, + mapper_event_store: None, link_request_store: None, magic_link_store: None, magic_link_secret_store: None, @@ -1789,6 +1847,32 @@ pub(crate) mod test_mocks { } } + #[async_trait] + impl MapperEventStore for MockStorage { + async fn record( + &self, + event: &v_model::NewMapperEvent, + ) -> Result { + self.mapper_event_store + .as_ref() + .unwrap() + .record(event) + .await + } + + async fn list( + &self, + filter: v_model::storage::MapperEventFilter, + pagination: &ListPagination, + ) -> Result, v_model::storage::StoreError> { + self.mapper_event_store + .as_ref() + .unwrap() + .list(filter, pagination) + .await + } + } + #[async_trait] impl LinkRequestStore for MockStorage { async fn get( diff --git a/v-api/src/context/user.rs b/v-api/src/context/user.rs index 74643544..4a24a8d8 100644 --- a/v-api/src/context/user.rs +++ b/v-api/src/context/user.rs @@ -368,6 +368,7 @@ where pub async fn create_api_user( &self, caller: &Caller, + id: TypedUuid, permissions: Permissions, groups: Vec>, ) -> ResourceResult, StoreError> { @@ -381,7 +382,7 @@ where && caller.can_grant_all(&group_permissions) { let new_user = NewApiUser { - id: TypedUuid::new_v4(), + id, permissions, groups: groups.into_iter().map(|g| g.id).collect(), }; diff --git a/v-api/src/endpoints/api_user.rs b/v-api/src/endpoints/api_user.rs index b2354858..2e05a24e 100644 --- a/v-api/src/endpoints/api_user.rs +++ b/v-api/src/endpoints/api_user.rs @@ -239,7 +239,7 @@ where let info = ctx .user - .create_api_user(&caller, body.permissions, groups) + .create_api_user(&caller, TypedUuid::new_v4(), body.permissions, groups) .await?; let filter = ApiUserProviderFilter { diff --git a/v-api/src/endpoints/mappers.rs b/v-api/src/endpoints/mappers.rs index 0e3f88e0..06595e34 100644 --- a/v-api/src/endpoints/mappers.rs +++ b/v-api/src/endpoints/mappers.rs @@ -2,7 +2,9 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use dropshot::{HttpError, HttpResponseCreated, HttpResponseOk, RequestContext}; +use dropshot::{ + ClientErrorStatusCode, HttpError, HttpResponseCreated, HttpResponseOk, RequestContext, +}; use newtype_uuid::TypedUuid; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -16,7 +18,7 @@ use v_model::{ use crate::{ context::{ApiContext, VContextWithCaller}, permissions::{VAppPermission, VPermission}, - response::bad_request, + response::{bad_request, client_error}, }; #[derive(Debug, Deserialize, JsonSchema)] @@ -97,6 +99,16 @@ where T::AppPermissions: Permission + From + AsScope + PermissionStorage, { let (ctx, caller) = rqctx.as_ctx().await?; + + // Ephemeral mappers cannot be deleted via the API — they are managed + // via service configuration. + if ctx.mapping.is_ephemeral(&path.mapper_id) { + return Err(client_error( + ClientErrorStatusCode::CONFLICT, + "Ephemeral mappers are managed via service configuration and cannot be deleted at runtime", + )); + } + Ok(HttpResponseOk( ctx.mapping.remove_mapper(&caller, &path.mapper_id).await?, )) diff --git a/v-api/src/lib.rs b/v-api/src/lib.rs index 274ecac9..30e80e09 100644 --- a/v-api/src/lib.rs +++ b/v-api/src/lib.rs @@ -14,10 +14,10 @@ mod secrets; mod util; pub use context::{ - ApiContext, BasePermissions, CallerExtension, ExtensionError, GroupContext, LinkContext, - LoginContext, MagicLinkContext, MagicLinkMessage, MagicLinkTarget, MappingContext, - OAuthContext, UserContext, VApiStorage, VContext, VContextBuilder, VContextBuilderError, - VContextError, VContextWithCaller, auth::SecretContext, + ApiContext, BasePermissions, CallerExtension, EphemeralMapperConfig, ExtensionError, + GroupContext, LinkContext, LoginContext, MagicLinkContext, MagicLinkMessage, MagicLinkTarget, + MappingContext, OAuthContext, UserContext, VApiStorage, VContext, VContextBuilder, + VContextBuilderError, VContextError, VContextWithCaller, auth::SecretContext, }; pub use util::response; diff --git a/v-model/migrations/2025-05-18-150000_mapper_event/down.sql b/v-model/migrations/2025-05-18-150000_mapper_event/down.sql new file mode 100644 index 00000000..8b576fe3 --- /dev/null +++ b/v-model/migrations/2025-05-18-150000_mapper_event/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS mapper_event; diff --git a/v-model/migrations/2025-05-18-150000_mapper_event/up.sql b/v-model/migrations/2025-05-18-150000_mapper_event/up.sql new file mode 100644 index 00000000..d5fa34ce --- /dev/null +++ b/v-model/migrations/2025-05-18-150000_mapper_event/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE mapper_event ( + id UUID PRIMARY KEY, + mapper_id UUID NOT NULL, + mapper_name VARCHAR NOT NULL, + user_id UUID NOT NULL, + rule JSONB NOT NULL, + ephemeral BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/v-model/src/db.rs b/v-model/src/db.rs index 42aea49e..6b043402 100644 --- a/v-model/src/db.rs +++ b/v-model/src/db.rs @@ -13,8 +13,8 @@ use crate::{ schema::{ access_groups, api_key, api_user, api_user_access_token, api_user_contact_email, api_user_provider, link_request, login_attempt, magic_link_attempt, magic_link_client, - magic_link_client_redirect_uri, magic_link_client_secret, mapper, oauth_client, - oauth_client_redirect_uri, oauth_client_secret, + magic_link_client_redirect_uri, magic_link_client_secret, mapper, mapper_event, + oauth_client, oauth_client_redirect_uri, oauth_client_secret, }, schema_ext::{LoginAttemptState, MagicLinkAttemptState}, }; @@ -199,6 +199,18 @@ pub struct MapperModel { pub deleted_at: Option>, } +#[derive(Debug, Deserialize, Serialize, Queryable, Insertable)] +#[diesel(table_name = mapper_event)] +pub struct MapperEventModel { + pub id: Uuid, + pub mapper_id: Uuid, + pub mapper_name: String, + pub user_id: Uuid, + pub rule: Value, + pub ephemeral: bool, + pub created_at: DateTime, +} + #[derive(Debug, Deserialize, Serialize, Queryable, Insertable)] #[diesel(table_name = link_request)] pub struct LinkRequestModel { diff --git a/v-model/src/lib.rs b/v-model/src/lib.rs index b02c4efa..b3cf26ce 100644 --- a/v-model/src/lib.rs +++ b/v-model/src/lib.rs @@ -6,8 +6,8 @@ use chrono::{DateTime, Utc}; use db::{ AccessGroupModel, ApiKeyModel, ApiUserAccessTokenModel, ApiUserContactEmailModel, ApiUserModel, ApiUserProviderModel, LinkRequestModel, LoginAttemptModel, MagicLinkAttemptModel, - MagicLinkModel, MagicLinkRedirectUriModel, MagicLinkSecretModel, MapperModel, OAuthClientModel, - OAuthClientRedirectUriModel, OAuthClientSecretModel, + MagicLinkModel, MagicLinkRedirectUriModel, MagicLinkSecretModel, MapperEventModel, MapperModel, + OAuthClientModel, OAuthClientRedirectUriModel, OAuthClientSecretModel, }; use newtype_uuid::{GenericUuid, TypedUuid, TypedUuidKind, TypedUuidTag}; use partial_struct::partial; @@ -703,6 +703,8 @@ pub struct Mapper { pub activations: Option, pub max_activations: Option, #[partial(NewMapper(skip))] + pub ephemeral: bool, + #[partial(NewMapper(skip))] pub depleted_at: Option>, #[partial(NewMapper(skip))] pub created_at: DateTime, @@ -720,6 +722,8 @@ impl From for Mapper { rule: value.rule, activations: value.activations, max_activations: value.max_activations, + // By definition a stored mapper is not ephemeral + ephemeral: false, depleted_at: value.depleted_at, created_at: value.created_at, updated_at: value.updated_at, @@ -728,6 +732,59 @@ impl From for Mapper { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MapperSource { + /// Created via the API, persisted in the database, supports activation limits + Dynamic, + /// Loaded from service configuration, in-memory only, no activation limits + Ephemeral, +} + +#[derive(JsonSchema)] +pub enum MapperEventId {} +impl TypedUuidKind for MapperEventId { + fn tag() -> TypedUuidTag { + const TAG: TypedUuidTag = TypedUuidTag::new("mapper-event"); + TAG + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct MapperEvent { + pub id: TypedUuid, + pub mapper_id: TypedUuid, + pub mapper_name: String, + pub user_id: TypedUuid, + pub rule: Value, + pub ephemeral: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct NewMapperEvent { + pub id: TypedUuid, + pub mapper_id: TypedUuid, + pub mapper_name: String, + pub user_id: TypedUuid, + pub rule: Value, + pub ephemeral: bool, +} + +impl From for MapperEvent { + fn from(value: MapperEventModel) -> Self { + MapperEvent { + id: TypedUuid::from_untyped_uuid(value.id), + mapper_id: TypedUuid::from_untyped_uuid(value.mapper_id), + mapper_name: value.mapper_name, + user_id: TypedUuid::from_untyped_uuid(value.user_id), + rule: value.rule, + ephemeral: value.ephemeral, + created_at: value.created_at, + } + } +} + #[derive(JsonSchema)] pub enum LinkRequestId {} impl TypedUuidKind for LinkRequestId { diff --git a/v-model/src/schema.rs b/v-model/src/schema.rs index 1de63801..c7700b65 100644 --- a/v-model/src/schema.rs +++ b/v-model/src/schema.rs @@ -168,6 +168,18 @@ diesel::table! { } } +diesel::table! { + mapper_event (id) { + id -> Uuid, + mapper_id -> Uuid, + mapper_name -> Varchar, + user_id -> Uuid, + rule -> Jsonb, + ephemeral -> Bool, + created_at -> Timestamptz, + } +} + diesel::table! { mapper (id) { id -> Uuid, @@ -262,6 +274,7 @@ diesel::allow_tables_to_appear_in_same_query!( magic_link_client_redirect_uri, magic_link_client_secret, mapper, + mapper_event, oauth_client, oauth_client_redirect_uri, oauth_client_secret, diff --git a/v-model/src/storage/mod.rs b/v-model/src/storage/mod.rs index 8102f3be..2ae679fe 100644 --- a/v-model/src/storage/mod.rs +++ b/v-model/src/storage/mod.rs @@ -19,12 +19,13 @@ use crate::{ AccessGroup, AccessGroupId, AccessToken, AccessTokenId, ApiKey, ApiKeyId, ApiUserContactEmail, ApiUserInfo, ApiUserProvider, LinkRequest, LinkRequestId, LoginAttempt, LoginAttemptId, MagicLink, MagicLinkAttempt, MagicLinkAttemptId, MagicLinkId, MagicLinkRedirectUri, - MagicLinkRedirectUriId, MagicLinkSecret, MagicLinkSecretId, Mapper, MapperId, NewAccessGroup, - NewAccessToken, NewApiKey, NewApiUser, NewApiUserContactEmail, NewApiUserProvider, - NewLinkRequest, NewLoginAttempt, NewMagicLink, NewMagicLinkAttempt, NewMagicLinkRedirectUri, - NewMagicLinkSecret, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, - OAuthClient, OAuthClientId, OAuthClientRedirectUri, OAuthClientSecret, OAuthRedirectUriId, - OAuthSecretId, UserContactEmailId, UserId, UserProviderId, + MagicLinkRedirectUriId, MagicLinkSecret, MagicLinkSecretId, Mapper, MapperEvent, MapperEventId, + MapperId, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserContactEmail, + NewApiUserProvider, NewLinkRequest, NewLoginAttempt, NewMagicLink, NewMagicLinkAttempt, + NewMagicLinkRedirectUri, NewMagicLinkSecret, NewMapper, NewMapperEvent, NewOAuthClient, + NewOAuthClientRedirectUri, NewOAuthClientSecret, OAuthClient, OAuthClientId, + OAuthClientRedirectUri, OAuthClientSecret, OAuthRedirectUriId, OAuthSecretId, + UserContactEmailId, UserId, UserProviderId, schema_ext::{LoginAttemptState, MagicLinkAttemptState}, }; @@ -476,6 +477,36 @@ pub trait MapperStore { async fn delete(&self, id: &TypedUuid) -> Result, StoreError>; } +#[derive(Debug, Default, PartialEq)] +pub struct MapperEventFilter { + pub id: Option>>, + pub mapper_id: Option>>, + pub ephemeral: Option, +} + +impl MapperEventFilter { + pub fn mapper_id(mut self, mapper_id: Option>>) -> Self { + self.mapper_id = mapper_id; + self + } + + pub fn ephemeral(mut self, ephemeral: Option) -> Self { + self.ephemeral = ephemeral; + self + } +} + +#[cfg_attr(feature = "mock", automock)] +#[async_trait] +pub trait MapperEventStore { + async fn record(&self, event: &NewMapperEvent) -> Result; + async fn list( + &self, + filter: MapperEventFilter, + pagination: &ListPagination, + ) -> Result, StoreError>; +} + #[derive(Debug, Default, PartialEq)] pub struct LinkRequestFilter { pub id: Option>>, diff --git a/v-model/src/storage/postgres.rs b/v-model/src/storage/postgres.rs index d645bc55..02503277 100644 --- a/v-model/src/storage/postgres.rs +++ b/v-model/src/storage/postgres.rs @@ -20,24 +20,25 @@ use crate::{ ApiUserContactEmail, ApiUserInfo, ApiUserProvider, LinkRequest, LinkRequestId, LoginAttempt, LoginAttemptId, MagicLink, MagicLinkAttempt, MagicLinkAttemptId, MagicLinkId, MagicLinkRedirectUri, MagicLinkRedirectUriId, MagicLinkSecret, MagicLinkSecretId, Mapper, - MapperId, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserContactEmail, - NewApiUserProvider, NewLinkRequest, NewLoginAttempt, NewMagicLink, NewMagicLinkAttempt, - NewMagicLinkRedirectUri, NewMagicLinkSecret, NewMapper, NewOAuthClient, - NewOAuthClientRedirectUri, NewOAuthClientSecret, OAuthClient, OAuthClientId, + MapperEvent, MapperId, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, + NewApiUserContactEmail, NewApiUserProvider, NewLinkRequest, NewLoginAttempt, NewMagicLink, + NewMagicLinkAttempt, NewMagicLinkRedirectUri, NewMagicLinkSecret, NewMapper, NewMapperEvent, + NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, OAuthClient, OAuthClientId, OAuthClientRedirectUri, OAuthClientSecret, OAuthRedirectUriId, OAuthSecretId, UserContactEmailId, UserId, UserProviderId, db::{ AccessGroupModel, ApiKeyModel, ApiUserAccessTokenModel, ApiUserContactEmailModel, ApiUserModel, ApiUserProviderModel, LinkRequestModel, LoginAttemptModel, MagicLinkAttemptModel, MagicLinkModel, MagicLinkRedirectUriModel, MagicLinkSecretModel, - MapperModel, OAuthClientModel, OAuthClientRedirectUriModel, OAuthClientSecretModel, + MapperEventModel, MapperModel, OAuthClientModel, OAuthClientRedirectUriModel, + OAuthClientSecretModel, }, permissions::Permission, schema::{ access_groups, api_key, api_user, api_user_access_token, api_user_contact_email, api_user_provider, link_request, login_attempt, magic_link_attempt, magic_link_client, - magic_link_client_redirect_uri, magic_link_client_secret, mapper, oauth_client, - oauth_client_redirect_uri, oauth_client_secret, + magic_link_client_redirect_uri, magic_link_client_secret, mapper, mapper_event, + oauth_client, oauth_client_redirect_uri, oauth_client_secret, }, schema_ext::MagicLinkAttemptState, storage::{LinkRequestFilter, LinkRequestStore, StoreError}, @@ -48,8 +49,9 @@ use super::{ ApiKeyStore, ApiUserContactEmailFilter, ApiUserContactEmailStore, ApiUserFilter, ApiUserProviderFilter, ApiUserProviderStore, ApiUserStore, ListPagination, LoginAttemptFilter, LoginAttemptStore, MagicLinkAttemptFilter, MagicLinkAttemptStore, MagicLinkFilter, - MagicLinkRedirectUriStore, MagicLinkSecretStore, MagicLinkStore, MapperFilter, MapperStore, - OAuthClientFilter, OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, + MagicLinkRedirectUriStore, MagicLinkSecretStore, MagicLinkStore, MapperEventFilter, + MapperEventStore, MapperFilter, MapperStore, OAuthClientFilter, OAuthClientRedirectUriStore, + OAuthClientSecretStore, OAuthClientStore, }; pub type DbPool = Pool>; @@ -1507,6 +1509,70 @@ impl MapperStore for PostgresStore { } } +#[async_trait] +impl MapperEventStore for PostgresStore { + #[instrument(skip(self), err(Debug))] + async fn record(&self, event: &NewMapperEvent) -> Result { + tracing::trace!("Recording mapper event"); + + let model: MapperEventModel = insert_into(mapper_event::dsl::mapper_event) + .values(( + mapper_event::id.eq(event.id.into_untyped_uuid()), + mapper_event::mapper_id.eq(event.mapper_id.into_untyped_uuid()), + mapper_event::mapper_name.eq(event.mapper_name.clone()), + mapper_event::user_id.eq(event.user_id.into_untyped_uuid()), + mapper_event::rule.eq(event.rule.clone()), + mapper_event::ephemeral.eq(event.ephemeral), + )) + .get_result_async(&*self.pool.get().await?) + .await?; + + Ok(model.into()) + } + + #[instrument(skip(self), err(Debug))] + async fn list( + &self, + filter: MapperEventFilter, + pagination: &ListPagination, + ) -> Result, StoreError> { + tracing::trace!("Listing mapper events"); + + let mut query = mapper_event::dsl::mapper_event.into_boxed(); + + let MapperEventFilter { + id, + mapper_id, + ephemeral, + } = filter; + + if let Some(id) = id { + query = query + .filter(mapper_event::id.eq_any(id.into_iter().map(|id| id.into_untyped_uuid()))); + } + + if let Some(mapper_id) = mapper_id { + query = query.filter( + mapper_event::mapper_id + .eq_any(mapper_id.into_iter().map(|id| id.into_untyped_uuid())), + ); + } + + if let Some(ephemeral) = ephemeral { + query = query.filter(mapper_event::ephemeral.eq(ephemeral)); + } + + let results = query + .offset(pagination.offset) + .limit(pagination.limit) + .order(mapper_event::created_at.desc()) + .get_results_async::(&*self.pool.get().await?) + .await?; + + Ok(results.into_iter().map(|model| model.into()).collect()) + } +} + #[async_trait] impl LinkRequestStore for PostgresStore { #[instrument(skip(self), err(Debug))]