From bc256e89596cb70b314d44122ac61f450875e9d5 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Mon, 18 May 2026 11:04:30 -0500 Subject: [PATCH 01/11] Add suppor for ephemeral mappers --- Cargo.lock | 7 + v-api/Cargo.toml | 2 +- v-api/src/context/mapping.rs | 80 ++++++++-- v-api/src/context/mod.rs | 143 +++++++++++++++--- v-api/src/context/user.rs | 3 +- v-api/src/endpoints/api_user.rs | 2 +- v-api/src/endpoints/mappers.rs | 16 +- v-api/src/lib.rs | 8 +- .../2025-05-18-150000_mapper_event/down.sql | 1 + .../2025-05-18-150000_mapper_event/up.sql | 9 ++ v-model/src/db.rs | 16 +- v-model/src/lib.rs | 57 ++++++- v-model/src/schema.rs | 13 ++ v-model/src/storage/mod.rs | 43 +++++- v-model/src/storage/postgres.rs | 84 ++++++++-- 15 files changed, 416 insertions(+), 68 deletions(-) create mode 100644 v-model/migrations/2025-05-18-150000_mapper_event/down.sql create mode 100644 v-model/migrations/2025-05-18-150000_mapper_event/up.sql 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..effc615e 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,15 @@ where previous } + pub fn set_ephemeral_mappers(&mut self, mappers: Vec) { + self.ephemeral_mappers = mappers; + } + + /// Returns true if the given mapper ID belongs to an ephemeral mapper. + 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 +72,18 @@ 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?; + + // Merge in ephemeral mappers. These are always included since they + // cannot be depleted or deleted. + mappers.extend(self.ephemeral_mappers.iter().cloned()); + + Ok(mappers) } else { resource_restricted() } @@ -100,6 +117,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 +129,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 +145,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 +177,18 @@ where }; if apply { + // Record the mapper event for audit purposes + 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 +212,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: *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..ed313a3c 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,9 +490,27 @@ 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 = match api_user_providers.len() { + 0 => TypedUuid::new_v4(), + 1 => api_user_providers[0].user_id, + _ => { + 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()?; @@ -518,7 +541,7 @@ where let user = self .user - .create_api_user(caller, mapped_permissions, groups) + .create_api_user(caller, user_id, mapped_permissions, groups) .await .inner_err_into()?; @@ -589,18 +612,9 @@ where 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" - ); - - resource_error(ApiError::from(StoreError::InvariantFailed( - "Multiple providers for external id found".to_string(), - ))) - } + // The multi-provider case is handled before the match above, + // so this arm is unreachable. + _ => unreachable!(), } } @@ -741,6 +755,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 \u2014 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 +788,7 @@ pub struct VContextBuilder { storage: Option>>, storage_url: Option, keys: Option>, + mappers: Vec, #[cfg(feature = "sagas")] saga: Option<(TypedUuid, Option)>, } @@ -787,6 +815,7 @@ where storage: None, storage_url: None, keys: None, + mappers: Vec::new(), #[cfg(feature = "sagas")] saga: None, } @@ -827,6 +856,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 +944,37 @@ 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, + 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 +1298,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 +1367,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 +1393,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 +1856,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..1944660c 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; @@ -728,6 +728,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))] From 5cd2e43900e02a1208a4731c57047bdb75be4e79 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Mon, 18 May 2026 11:07:01 -0500 Subject: [PATCH 02/11] Add API indicator for ephemeral mappers --- v-api/src/context/mod.rs | 1 + v-model/src/lib.rs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index ed313a3c..912ff38f 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -958,6 +958,7 @@ where rule: config.rule, activations: None, max_activations: None, + ephemeral: true, depleted_at: None, created_at: Utc::now(), updated_at: Utc::now(), diff --git a/v-model/src/lib.rs b/v-model/src/lib.rs index 1944660c..b3cf26ce 100644 --- a/v-model/src/lib.rs +++ b/v-model/src/lib.rs @@ -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, From 1feae7b149d83058d80d554a756faf28284f4b56 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Mon, 18 May 2026 16:41:36 -0500 Subject: [PATCH 03/11] Remove extraneous reference --- v-api/src/context/mapping.rs | 10 +++------- v-api/src/context/mod.rs | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/v-api/src/context/mapping.rs b/v-api/src/context/mapping.rs index effc615e..b38ee78f 100644 --- a/v-api/src/context/mapping.rs +++ b/v-api/src/context/mapping.rs @@ -54,7 +54,6 @@ where self.ephemeral_mappers = mappers; } - /// Returns true if the given mapper ID belongs to an ephemeral mapper. pub fn is_ephemeral(&self, id: &TypedUuid) -> bool { self.ephemeral_mappers.iter().any(|m| &m.id == id) } @@ -78,9 +77,6 @@ where &ListPagination::unlimited(), ) .await?; - - // Merge in ephemeral mappers. These are always included since they - // cannot be depleted or deleted. mappers.extend(self.ephemeral_mappers.iter().cloned()); Ok(mappers) @@ -117,7 +113,7 @@ where &self, caller: &Caller, info: &UserInfo, - user_id: &TypedUuid, + user_id: TypedUuid, ) -> ResourceResult<(Permissions, BTreeSet>), StoreError> { let mut mapped_permissions = Permissions::new(); let mut mapped_groups = BTreeSet::new(); @@ -216,14 +212,14 @@ where async fn record_mapper_event( &self, mapper: &Mapper, - user_id: &TypedUuid, + 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: *user_id, + user_id, rule: mapper.rule.clone(), ephemeral, }; diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index 912ff38f..43c9b263 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -510,7 +510,7 @@ where let (mapped_permissions, mapped_groups) = self .mapping - .get_mapped_fields(caller, &info, &user_id) + .get_mapped_fields(caller, &info, user_id) .await .inner_err_into()?; From 63e6acc5ec9984ef274ad84004c4afbb2c461474 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Mon, 18 May 2026 16:42:19 -0500 Subject: [PATCH 04/11] Nit --- v-api/src/context/mapping.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v-api/src/context/mapping.rs b/v-api/src/context/mapping.rs index b38ee78f..d71fb479 100644 --- a/v-api/src/context/mapping.rs +++ b/v-api/src/context/mapping.rs @@ -148,7 +148,7 @@ where let apply = if !permissions.is_empty() || !groups.is_empty() { if is_ephemeral { - // Ephemeral mappers always apply — no activation gating + // 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 From 9cfcc5470041100747063a85ec2d0e5a858a4c92 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Mon, 18 May 2026 16:46:06 -0500 Subject: [PATCH 05/11] Cleanup user count branch checks --- v-api/src/context/mapping.rs | 3 +- v-api/src/context/mod.rs | 162 ++++++++++++++++------------------- 2 files changed, 78 insertions(+), 87 deletions(-) diff --git a/v-api/src/context/mapping.rs b/v-api/src/context/mapping.rs index d71fb479..3b0d7ba1 100644 --- a/v-api/src/context/mapping.rs +++ b/v-api/src/context/mapping.rs @@ -173,7 +173,8 @@ where }; if apply { - // Record the mapper event for audit purposes + // 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 diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index 43c9b263..f7c1cf14 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -493,9 +493,9 @@ where // 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 = match api_user_providers.len() { - 0 => TypedUuid::new_v4(), - 1 => api_user_providers[0].user_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(), @@ -517,104 +517,94 @@ where 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()?; + if is_new_user { + tracing::info!( + ?mapped_permissions, + ?mapped_groups, + "Did not find any existing users. Registering a new user." + ); - let user = self - .user - .create_api_user(caller, user_id, mapped_permissions, groups) - .await - .inner_err_into()?; + // 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)) - } - // The multi-provider case is handled before the match above, - // so this arm is unreachable. - _ => unreachable!(), + tracing::info!(?updated_user.user.id, "Updated user permissions and groups"); + + Ok((updated_user, provider)) } } From 83d83eb39c69376af00c3d20c03e93c172915560 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Mon, 18 May 2026 16:46:55 -0500 Subject: [PATCH 06/11] Typo fixgs --- v-api/src/context/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index f7c1cf14..8fc086ca 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -748,7 +748,7 @@ 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 \u2014 they +/// 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 { From f80d61f1c1ee40e1593a1e4892f10284d0b351ab Mon Sep 17 00:00:00 2001 From: augustuswm Date: Wed, 20 May 2026 09:44:48 -0500 Subject: [PATCH 07/11] Validate ephemeral mappers. Check for duplicates --- v-api/src/context/mod.rs | 45 +++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index 8fc086ca..1ff7c970 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; #[cfg(feature = "sagas")] use slog::Logger; -use std::{fmt::Debug, future::Future, path::PathBuf, sync::Arc}; +use std::{collections::BTreeSet, fmt::Debug, future::Future, path::PathBuf, sync::Arc}; use thiserror::Error; use tracing::instrument; use user::{RegisteredAccessToken, UserContextError}; @@ -762,6 +762,10 @@ pub struct EphemeralMapperConfig { pub enum VContextBuilderError { #[error("Conflicting configuration, only one of {0} and {1} can be set")] ConfigConflict(String, String), + #[error("Duplicate mapper rule")] + DuplicateMapperRule(String), + #[error("Invalid mapper rule")] + InvalidMapperRule(String), #[error("{0} must be set to build a VContext")] MissingRequiredConfiguration(String), #[error("Failed to connect to storage")] @@ -962,9 +966,37 @@ where count = ephemeral_mappers.len(), "Loaded ephemeral mappers from configuration" ); - } - mapping_ctx.set_ephemeral_mappers(ephemeral_mappers); + // Validate all ephemeral mappers before setting them + for mapper in &ephemeral_mappers { + if !mapping_ctx.validate(&mapper.rule) { + return Err(VContextBuilderError::InvalidMapperRule( + mapper.name.to_string(), + )); + } + } + + // Ensure that we do not have any duplicate ephemeral mappers + let (_, duplicates) = ephemeral_mappers.iter().map(|m| &m.name).fold( + (BTreeSet::default(), BTreeSet::default()), + |(mut names, mut duplicates), name| { + if names.contains(&name) { + duplicates.insert(name); + } else { + names.insert(name); + } + (names, duplicates) + }, + ); + + if !duplicates.is_empty() { + return Err(VContextBuilderError::DuplicateMapperRule( + duplicates.first().unwrap().to_string(), + )); + } + + mapping_ctx.set_ephemeral_mappers(ephemeral_mappers); + } #[cfg(feature = "sagas")] let saga = if let Some((node_id, logger)) = self.saga { @@ -989,13 +1021,6 @@ where #[cfg(feature = "sagas")] saga, }) - - // VContext::::new(public_url, param_path, storage, jwt, keys) - // .await - // .map_err(|err| { - // tracing::error!(?err, "Failed to construct VContext"); - // VContextBuilderError::VContext(err) - // }) } } From aed3e4c6a96c0a87e375608edf0d2ea0b8e9558d Mon Sep 17 00:00:00 2001 From: augustuswm Date: Wed, 20 May 2026 09:45:44 -0500 Subject: [PATCH 08/11] Fix migration year --- .../down.sql | 0 .../up.sql | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename v-model/migrations/{2025-05-18-150000_mapper_event => 2026-05-18-150000_mapper_event}/down.sql (100%) rename v-model/migrations/{2025-05-18-150000_mapper_event => 2026-05-18-150000_mapper_event}/up.sql (100%) diff --git a/v-model/migrations/2025-05-18-150000_mapper_event/down.sql b/v-model/migrations/2026-05-18-150000_mapper_event/down.sql similarity index 100% rename from v-model/migrations/2025-05-18-150000_mapper_event/down.sql rename to v-model/migrations/2026-05-18-150000_mapper_event/down.sql diff --git a/v-model/migrations/2025-05-18-150000_mapper_event/up.sql b/v-model/migrations/2026-05-18-150000_mapper_event/up.sql similarity index 100% rename from v-model/migrations/2025-05-18-150000_mapper_event/up.sql rename to v-model/migrations/2026-05-18-150000_mapper_event/up.sql From 48094f92adf1da486ccdb8a563a261081479a555 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Wed, 20 May 2026 09:57:54 -0500 Subject: [PATCH 09/11] Empemeral -> Preset --- v-api/src/context/mapping.rs | 31 ++++++++---------- v-api/src/context/mod.rs | 32 +++++++++---------- v-api/src/endpoints/mappers.rs | 6 ++-- v-api/src/lib.rs | 6 ++-- .../2026-05-18-150000_mapper_event/up.sql | 2 +- v-model/src/db.rs | 2 +- v-model/src/lib.rs | 14 ++++---- v-model/src/schema.rs | 2 +- v-model/src/storage/mod.rs | 6 ++-- v-model/src/storage/postgres.rs | 8 ++--- 10 files changed, 53 insertions(+), 56 deletions(-) diff --git a/v-api/src/context/mapping.rs b/v-api/src/context/mapping.rs index 3b0d7ba1..6a0dc63c 100644 --- a/v-api/src/context/mapping.rs +++ b/v-api/src/context/mapping.rs @@ -22,7 +22,7 @@ use crate::{ pub struct MappingContext { engine: Option>>, storage: Arc>, - ephemeral_mappers: Vec, + preset_mappers: Vec, } impl MappingContext @@ -33,7 +33,7 @@ where Self { engine: None, storage, - ephemeral_mappers: Vec::new(), + preset_mappers: Vec::new(), } } @@ -50,12 +50,12 @@ where previous } - pub fn set_ephemeral_mappers(&mut self, mappers: Vec) { - self.ephemeral_mappers = mappers; + pub fn set_preset_mappers(&mut self, mappers: Vec) { + self.preset_mappers = mappers; } - pub fn is_ephemeral(&self, id: &TypedUuid) -> bool { - self.ephemeral_mappers.iter().any(|m| &m.id == id) + pub fn is_preset(&self, id: &TypedUuid) -> bool { + self.preset_mappers.iter().any(|m| &m.id == id) } pub fn validate(&self, value: &Value) -> bool { @@ -77,7 +77,7 @@ where &ListPagination::unlimited(), ) .await?; - mappers.extend(self.ephemeral_mappers.iter().cloned()); + mappers.extend(self.preset_mappers.iter().cloned()); Ok(mappers) } else { @@ -125,8 +125,8 @@ where // instead handle mappers that become depleted before we can evaluate them at evaluation // time. for mapper in self.get_mappers(caller, false).await? { - let is_ephemeral = self.is_ephemeral(&mapper.id); - tracing::trace!(?mapper.name, is_ephemeral, "Attempt to run mapper"); + let is_preset = self.is_preset(&mapper.id); + tracing::trace!(?mapper.name, is_preset, "Attempt to run mapper"); // Try to transform this mapper into a mapping let mapping = engine.create_mapping(mapper.clone()); @@ -147,8 +147,8 @@ where }; let apply = if !permissions.is_empty() || !groups.is_empty() { - if is_ephemeral { - // Ephemeral mappers always apply - no activation gating + if is_preset { + // Preset mappers always apply - no activation gating true } else if mapper.max_activations.is_some() { // Dynamic mappers with activation limits need to consume an activation @@ -175,10 +175,7 @@ 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 - { + if let Err(err) = self.record_mapper_event(&mapper, user_id, is_preset).await { tracing::warn!( ?err, mapper_name = ?mapper.name, @@ -214,7 +211,7 @@ where &self, mapper: &Mapper, user_id: TypedUuid, - ephemeral: bool, + preset: bool, ) -> Result<(), StoreError> { let event = NewMapperEvent { id: TypedUuid::new_v4(), @@ -222,7 +219,7 @@ where mapper_name: mapper.name.clone(), user_id, rule: mapper.rule.clone(), - ephemeral, + preset, }; MapperEventStore::record(&*self.storage, &event) diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index 1ff7c970..a107a41a 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -745,13 +745,13 @@ where } } -/// Configuration for an ephemeral mapper that is loaded from service configuration. +/// Configuration for a preset mapper that is loaded from service configuration. /// -/// Ephemeral mappers exist only in memory for the lifetime of the process. They cannot +/// Preset 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 { +pub struct PresetMapperConfig { /// Human-readable name for this mapper pub name: String, /// The mapping rule as a JSON value (same format as dynamic mapper rules) @@ -782,7 +782,7 @@ pub struct VContextBuilder { storage: Option>>, storage_url: Option, keys: Option>, - mappers: Vec, + mappers: Vec, #[cfg(feature = "sagas")] saga: Option<(TypedUuid, Option)>, } @@ -850,7 +850,7 @@ where self } - pub fn with_mappers(mut self, mappers: Vec) -> Self { + pub fn with_mappers(mut self, mappers: Vec) -> Self { self.mappers = mappers; self } @@ -938,8 +938,8 @@ where group_ctx.clone(), )))); - // Convert ephemeral mapper configs into Mapper structs with deterministic IDs - let ephemeral_mappers: Vec = self + // Convert preset mapper configs into Mapper structs with deterministic IDs + let preset_mappers: Vec = self .mappers .into_iter() .map(|config| { @@ -952,7 +952,7 @@ where rule: config.rule, activations: None, max_activations: None, - ephemeral: true, + preset: true, depleted_at: None, created_at: Utc::now(), updated_at: Utc::now(), @@ -961,14 +961,14 @@ where }) .collect(); - if !ephemeral_mappers.is_empty() { + if !preset_mappers.is_empty() { tracing::info!( - count = ephemeral_mappers.len(), - "Loaded ephemeral mappers from configuration" + count = preset_mappers.len(), + "Loaded preset mappers from configuration" ); - // Validate all ephemeral mappers before setting them - for mapper in &ephemeral_mappers { + // Validate all preset mappers before setting them + for mapper in &preset_mappers { if !mapping_ctx.validate(&mapper.rule) { return Err(VContextBuilderError::InvalidMapperRule( mapper.name.to_string(), @@ -976,8 +976,8 @@ where } } - // Ensure that we do not have any duplicate ephemeral mappers - let (_, duplicates) = ephemeral_mappers.iter().map(|m| &m.name).fold( + // Ensure that we do not have any duplicate preset mappers + let (_, duplicates) = preset_mappers.iter().map(|m| &m.name).fold( (BTreeSet::default(), BTreeSet::default()), |(mut names, mut duplicates), name| { if names.contains(&name) { @@ -995,7 +995,7 @@ where )); } - mapping_ctx.set_ephemeral_mappers(ephemeral_mappers); + mapping_ctx.set_preset_mappers(preset_mappers); } #[cfg(feature = "sagas")] diff --git a/v-api/src/endpoints/mappers.rs b/v-api/src/endpoints/mappers.rs index 06595e34..ae92b09c 100644 --- a/v-api/src/endpoints/mappers.rs +++ b/v-api/src/endpoints/mappers.rs @@ -100,12 +100,12 @@ where { let (ctx, caller) = rqctx.as_ctx().await?; - // Ephemeral mappers cannot be deleted via the API — they are managed + // Preset mappers cannot be deleted via the API — they are managed // via service configuration. - if ctx.mapping.is_ephemeral(&path.mapper_id) { + if ctx.mapping.is_preset(&path.mapper_id) { return Err(client_error( ClientErrorStatusCode::CONFLICT, - "Ephemeral mappers are managed via service configuration and cannot be deleted at runtime", + "Preset mappers are managed via service configuration and cannot be deleted at runtime", )); } diff --git a/v-api/src/lib.rs b/v-api/src/lib.rs index 30e80e09..c53c23f8 100644 --- a/v-api/src/lib.rs +++ b/v-api/src/lib.rs @@ -14,9 +14,9 @@ mod secrets; mod util; pub use context::{ - ApiContext, BasePermissions, CallerExtension, EphemeralMapperConfig, ExtensionError, - GroupContext, LinkContext, LoginContext, MagicLinkContext, MagicLinkMessage, MagicLinkTarget, - MappingContext, OAuthContext, UserContext, VApiStorage, VContext, VContextBuilder, + ApiContext, BasePermissions, CallerExtension, ExtensionError, GroupContext, LinkContext, + LoginContext, MagicLinkContext, MagicLinkMessage, MagicLinkTarget, MappingContext, + OAuthContext, PresetMapperConfig, UserContext, VApiStorage, VContext, VContextBuilder, VContextBuilderError, VContextError, VContextWithCaller, auth::SecretContext, }; pub use util::response; diff --git a/v-model/migrations/2026-05-18-150000_mapper_event/up.sql b/v-model/migrations/2026-05-18-150000_mapper_event/up.sql index d5fa34ce..5ed8d0a3 100644 --- a/v-model/migrations/2026-05-18-150000_mapper_event/up.sql +++ b/v-model/migrations/2026-05-18-150000_mapper_event/up.sql @@ -4,6 +4,6 @@ CREATE TABLE mapper_event ( mapper_name VARCHAR NOT NULL, user_id UUID NOT NULL, rule JSONB NOT NULL, - ephemeral BOOLEAN NOT NULL DEFAULT false, + preset 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 6b043402..88e88cb2 100644 --- a/v-model/src/db.rs +++ b/v-model/src/db.rs @@ -207,7 +207,7 @@ pub struct MapperEventModel { pub mapper_name: String, pub user_id: Uuid, pub rule: Value, - pub ephemeral: bool, + pub preset: bool, pub created_at: DateTime, } diff --git a/v-model/src/lib.rs b/v-model/src/lib.rs index b3cf26ce..0a373cb8 100644 --- a/v-model/src/lib.rs +++ b/v-model/src/lib.rs @@ -703,7 +703,7 @@ pub struct Mapper { pub activations: Option, pub max_activations: Option, #[partial(NewMapper(skip))] - pub ephemeral: bool, + pub preset: bool, #[partial(NewMapper(skip))] pub depleted_at: Option>, #[partial(NewMapper(skip))] @@ -722,8 +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, + // By definition a stored mapper is not a preset + preset: false, depleted_at: value.depleted_at, created_at: value.created_at, updated_at: value.updated_at, @@ -738,7 +738,7 @@ 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, + Preset, } #[derive(JsonSchema)] @@ -757,7 +757,7 @@ pub struct MapperEvent { pub mapper_name: String, pub user_id: TypedUuid, pub rule: Value, - pub ephemeral: bool, + pub preset: bool, pub created_at: DateTime, } @@ -768,7 +768,7 @@ pub struct NewMapperEvent { pub mapper_name: String, pub user_id: TypedUuid, pub rule: Value, - pub ephemeral: bool, + pub preset: bool, } impl From for MapperEvent { @@ -779,7 +779,7 @@ impl From for MapperEvent { mapper_name: value.mapper_name, user_id: TypedUuid::from_untyped_uuid(value.user_id), rule: value.rule, - ephemeral: value.ephemeral, + preset: value.preset, created_at: value.created_at, } } diff --git a/v-model/src/schema.rs b/v-model/src/schema.rs index c7700b65..ec898a09 100644 --- a/v-model/src/schema.rs +++ b/v-model/src/schema.rs @@ -175,7 +175,7 @@ diesel::table! { mapper_name -> Varchar, user_id -> Uuid, rule -> Jsonb, - ephemeral -> Bool, + preset -> Bool, created_at -> Timestamptz, } } diff --git a/v-model/src/storage/mod.rs b/v-model/src/storage/mod.rs index 2ae679fe..09fb582e 100644 --- a/v-model/src/storage/mod.rs +++ b/v-model/src/storage/mod.rs @@ -481,7 +481,7 @@ pub trait MapperStore { pub struct MapperEventFilter { pub id: Option>>, pub mapper_id: Option>>, - pub ephemeral: Option, + pub preset: Option, } impl MapperEventFilter { @@ -490,8 +490,8 @@ impl MapperEventFilter { self } - pub fn ephemeral(mut self, ephemeral: Option) -> Self { - self.ephemeral = ephemeral; + pub fn preset(mut self, preset: Option) -> Self { + self.preset = preset; self } } diff --git a/v-model/src/storage/postgres.rs b/v-model/src/storage/postgres.rs index 02503277..29023edd 100644 --- a/v-model/src/storage/postgres.rs +++ b/v-model/src/storage/postgres.rs @@ -1522,7 +1522,7 @@ impl MapperEventStore for PostgresStore { 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), + mapper_event::preset.eq(event.preset), )) .get_result_async(&*self.pool.get().await?) .await?; @@ -1543,7 +1543,7 @@ impl MapperEventStore for PostgresStore { let MapperEventFilter { id, mapper_id, - ephemeral, + preset, } = filter; if let Some(id) = id { @@ -1558,8 +1558,8 @@ impl MapperEventStore for PostgresStore { ); } - if let Some(ephemeral) = ephemeral { - query = query.filter(mapper_event::ephemeral.eq(ephemeral)); + if let Some(preset) = preset { + query = query.filter(mapper_event::preset.eq(preset)); } let results = query From 24f01fd2512143d4c91a94e50585db71b406fb9e Mon Sep 17 00:00:00 2001 From: augustuswm Date: Wed, 20 May 2026 10:45:45 -0500 Subject: [PATCH 10/11] Use enum for event source tracking --- v-api/src/context/mapping.rs | 9 ++-- v-api/src/context/mod.rs | 4 +- .../2026-05-18-150000_mapper_event/up.sql | 1 - v-model/src/db.rs | 3 +- v-model/src/lib.rs | 23 ++++------- v-model/src/schema.rs | 2 +- v-model/src/schema_ext.rs | 41 +++++++++++++++++++ v-model/src/storage/mod.rs | 14 +++---- v-model/src/storage/postgres.rs | 8 ++-- 9 files changed, 68 insertions(+), 37 deletions(-) diff --git a/v-api/src/context/mapping.rs b/v-api/src/context/mapping.rs index 6a0dc63c..8b00fa7f 100644 --- a/v-api/src/context/mapping.rs +++ b/v-api/src/context/mapping.rs @@ -6,7 +6,7 @@ use newtype_uuid::TypedUuid; use serde_json::Value; use std::{collections::BTreeSet, sync::Arc}; use v_model::{ - AccessGroupId, Mapper, MapperId, NewMapper, NewMapperEvent, Permissions, UserId, + AccessGroupId, Mapper, MapperId, MapperSource, NewMapper, NewMapperEvent, Permissions, UserId, permissions::Caller, storage::{ListPagination, MapperEventStore, MapperFilter, MapperStore, StoreError}, }; @@ -125,7 +125,7 @@ where // instead handle mappers that become depleted before we can evaluate them at evaluation // time. for mapper in self.get_mappers(caller, false).await? { - let is_preset = self.is_preset(&mapper.id); + let is_preset = mapper.source == MapperSource::Preset; tracing::trace!(?mapper.name, is_preset, "Attempt to run mapper"); // Try to transform this mapper into a mapping @@ -175,7 +175,7 @@ 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_preset).await { + if let Err(err) = self.record_mapper_event(&mapper, user_id).await { tracing::warn!( ?err, mapper_name = ?mapper.name, @@ -211,7 +211,6 @@ where &self, mapper: &Mapper, user_id: TypedUuid, - preset: bool, ) -> Result<(), StoreError> { let event = NewMapperEvent { id: TypedUuid::new_v4(), @@ -219,7 +218,7 @@ where mapper_name: mapper.name.clone(), user_id, rule: mapper.rule.clone(), - preset, + source: mapper.source, }; MapperEventStore::record(&*self.storage, &event) diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index a107a41a..b67ab533 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -24,7 +24,7 @@ use v_model::saga::{ view::SagaExecNodeId, }; use v_model::{ - AccessGroupId, ApiUserInfo, ApiUserProvider, LinkRequest, Mapper, NewApiUser, + AccessGroupId, ApiUserInfo, ApiUserProvider, LinkRequest, Mapper, MapperSource, NewApiUser, NewApiUserProvider, NewLinkRequest, UserId, UserProviderId, permissions::{Caller, Permission}, storage::{ @@ -952,7 +952,7 @@ where rule: config.rule, activations: None, max_activations: None, - preset: true, + source: MapperSource::Preset, depleted_at: None, created_at: Utc::now(), updated_at: Utc::now(), diff --git a/v-model/migrations/2026-05-18-150000_mapper_event/up.sql b/v-model/migrations/2026-05-18-150000_mapper_event/up.sql index 5ed8d0a3..51091279 100644 --- a/v-model/migrations/2026-05-18-150000_mapper_event/up.sql +++ b/v-model/migrations/2026-05-18-150000_mapper_event/up.sql @@ -4,6 +4,5 @@ CREATE TABLE mapper_event ( mapper_name VARCHAR NOT NULL, user_id UUID NOT NULL, rule JSONB NOT NULL, - preset 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 88e88cb2..62dcdff5 100644 --- a/v-model/src/db.rs +++ b/v-model/src/db.rs @@ -16,6 +16,7 @@ use crate::{ magic_link_client_redirect_uri, magic_link_client_secret, mapper, mapper_event, oauth_client, oauth_client_redirect_uri, oauth_client_secret, }, + schema_ext::MapperSource, schema_ext::{LoginAttemptState, MagicLinkAttemptState}, }; @@ -207,7 +208,7 @@ pub struct MapperEventModel { pub mapper_name: String, pub user_id: Uuid, pub rule: Value, - pub preset: bool, + pub source: MapperSource, pub created_at: DateTime, } diff --git a/v-model/src/lib.rs b/v-model/src/lib.rs index 0a373cb8..8f06d9fc 100644 --- a/v-model/src/lib.rs +++ b/v-model/src/lib.rs @@ -31,7 +31,7 @@ pub mod storage; pub use { permissions::{ArcMap, Permissions}, - schema_ext::LoginAttemptState, + schema_ext::{LoginAttemptState, MapperSource}, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -703,7 +703,7 @@ pub struct Mapper { pub activations: Option, pub max_activations: Option, #[partial(NewMapper(skip))] - pub preset: bool, + pub source: MapperSource, #[partial(NewMapper(skip))] pub depleted_at: Option>, #[partial(NewMapper(skip))] @@ -722,8 +722,8 @@ impl From for Mapper { rule: value.rule, activations: value.activations, max_activations: value.max_activations, - // By definition a stored mapper is not a preset - preset: false, + // By definition a stored mapper is dynamic + source: MapperSource::Dynamic, depleted_at: value.depleted_at, created_at: value.created_at, updated_at: value.updated_at, @@ -732,15 +732,6 @@ 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 - Preset, -} - #[derive(JsonSchema)] pub enum MapperEventId {} impl TypedUuidKind for MapperEventId { @@ -757,7 +748,7 @@ pub struct MapperEvent { pub mapper_name: String, pub user_id: TypedUuid, pub rule: Value, - pub preset: bool, + pub source: MapperSource, pub created_at: DateTime, } @@ -768,7 +759,7 @@ pub struct NewMapperEvent { pub mapper_name: String, pub user_id: TypedUuid, pub rule: Value, - pub preset: bool, + pub source: MapperSource, } impl From for MapperEvent { @@ -779,7 +770,7 @@ impl From for MapperEvent { mapper_name: value.mapper_name, user_id: TypedUuid::from_untyped_uuid(value.user_id), rule: value.rule, - preset: value.preset, + source: value.source, created_at: value.created_at, } } diff --git a/v-model/src/schema.rs b/v-model/src/schema.rs index ec898a09..4318d914 100644 --- a/v-model/src/schema.rs +++ b/v-model/src/schema.rs @@ -175,7 +175,7 @@ diesel::table! { mapper_name -> Varchar, user_id -> Uuid, rule -> Jsonb, - preset -> Bool, + source -> Varchar, created_at -> Timestamptz, } } diff --git a/v-model/src/schema_ext.rs b/v-model/src/schema_ext.rs index 8ac7126d..1c8cf65e 100644 --- a/v-model/src/schema_ext.rs +++ b/v-model/src/schema_ext.rs @@ -122,3 +122,44 @@ impl Display for MagicLinkAttemptState { } } } + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, Serialize, Deserialize, JsonSchema, +)] +#[diesel(sql_type = diesel::sql_types::Varchar)] +#[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 + Preset, +} + +impl ToSql for MapperSource { + fn to_sql(&self, out: &mut Output) -> serialize::Result { + match *self { + MapperSource::Dynamic => out.write_all(b"dynamic")?, + MapperSource::Preset => out.write_all(b"preset")?, + } + Ok(IsNull::No) + } +} + +impl FromSql for MapperSource { + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + match bytes.as_bytes() { + b"dynamic" => Ok(MapperSource::Dynamic), + b"preset" => Ok(MapperSource::Preset), + x => Err(format!("Unrecognized MapperSource variant {:?}", x).into()), + } + } +} + +impl Display for MapperSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MapperSource::Dynamic => write!(f, "dynamic"), + MapperSource::Preset => write!(f, "preset"), + } + } +} diff --git a/v-model/src/storage/mod.rs b/v-model/src/storage/mod.rs index 09fb582e..b704cccc 100644 --- a/v-model/src/storage/mod.rs +++ b/v-model/src/storage/mod.rs @@ -20,10 +20,10 @@ use crate::{ ApiUserInfo, ApiUserProvider, LinkRequest, LinkRequestId, LoginAttempt, LoginAttemptId, MagicLink, MagicLinkAttempt, MagicLinkAttemptId, MagicLinkId, MagicLinkRedirectUri, 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, + MapperId, MapperSource, 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}, @@ -481,7 +481,7 @@ pub trait MapperStore { pub struct MapperEventFilter { pub id: Option>>, pub mapper_id: Option>>, - pub preset: Option, + pub source: Option, } impl MapperEventFilter { @@ -490,8 +490,8 @@ impl MapperEventFilter { self } - pub fn preset(mut self, preset: Option) -> Self { - self.preset = preset; + pub fn source(mut self, source: Option) -> Self { + self.source = source; self } } diff --git a/v-model/src/storage/postgres.rs b/v-model/src/storage/postgres.rs index 29023edd..482285d6 100644 --- a/v-model/src/storage/postgres.rs +++ b/v-model/src/storage/postgres.rs @@ -1522,7 +1522,7 @@ impl MapperEventStore for PostgresStore { 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::preset.eq(event.preset), + mapper_event::source.eq(event.source), )) .get_result_async(&*self.pool.get().await?) .await?; @@ -1543,7 +1543,7 @@ impl MapperEventStore for PostgresStore { let MapperEventFilter { id, mapper_id, - preset, + source, } = filter; if let Some(id) = id { @@ -1558,8 +1558,8 @@ impl MapperEventStore for PostgresStore { ); } - if let Some(preset) = preset { - query = query.filter(mapper_event::preset.eq(preset)); + if let Some(source) = source { + query = query.filter(mapper_event::source.eq(source)); } let results = query From d0fc9b58fb1b6438fe21ce6b2c713ff00e4749d9 Mon Sep 17 00:00:00 2001 From: augustuswm Date: Thu, 21 May 2026 10:51:03 -0500 Subject: [PATCH 11/11] Do not drop returned event --- v-api/src/context/mapping.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/v-api/src/context/mapping.rs b/v-api/src/context/mapping.rs index 8b00fa7f..149df9e7 100644 --- a/v-api/src/context/mapping.rs +++ b/v-api/src/context/mapping.rs @@ -6,7 +6,8 @@ use newtype_uuid::TypedUuid; use serde_json::Value; use std::{collections::BTreeSet, sync::Arc}; use v_model::{ - AccessGroupId, Mapper, MapperId, MapperSource, NewMapper, NewMapperEvent, Permissions, UserId, + AccessGroupId, Mapper, MapperEvent, MapperId, MapperSource, NewMapper, NewMapperEvent, + Permissions, UserId, permissions::Caller, storage::{ListPagination, MapperEventStore, MapperFilter, MapperStore, StoreError}, }; @@ -211,7 +212,7 @@ where &self, mapper: &Mapper, user_id: TypedUuid, - ) -> Result<(), StoreError> { + ) -> Result { let event = NewMapperEvent { id: TypedUuid::new_v4(), mapper_id: mapper.id, @@ -221,8 +222,6 @@ where source: mapper.source, }; - MapperEventStore::record(&*self.storage, &event) - .await - .map(|_| ()) + MapperEventStore::record(&*self.storage, &event).await } }