Skip to main content

shared/models/
oauth_provider_mapping.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use compact_str::ToCompactString;
6use garde::Validate;
7use serde::{Deserialize, Serialize};
8use sqlx::{Row, postgres::PgRow};
9use std::{
10    collections::BTreeMap,
11    sync::{Arc, LazyLock},
12};
13use utoipa::ToSchema;
14
15#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
16#[serde(tag = "type", rename_all = "snake_case")]
17#[schema(no_recursion)]
18pub enum OAuthProviderMappingMatcher {
19    None,
20    Never,
21    And {
22        #[garde(dive)]
23        matchers: Vec<OAuthProviderMappingMatcher>,
24    },
25    Or {
26        #[garde(dive)]
27        matchers: Vec<OAuthProviderMappingMatcher>,
28    },
29    Not {
30        #[garde(dive)]
31        matcher: Box<OAuthProviderMappingMatcher>,
32    },
33    Scopes {
34        #[garde(length(max = 255), inner(length(max = 255)))]
35        #[schema(max_length = 255)]
36        scopes: Vec<compact_str::CompactString>,
37    },
38    FieldExists {
39        #[garde(length(chars, min = 1, max = 64))]
40        #[schema(min_length = 1, max_length = 64)]
41        #[serde(default)]
42        source: Option<compact_str::CompactString>,
43        #[garde(
44            length(chars, min = 1, max = 255),
45            custom(crate::utils::validate_json_path)
46        )]
47        #[schema(min_length = 1, max_length = 255)]
48        path: String,
49    },
50    FieldEquals {
51        #[garde(length(chars, min = 1, max = 64))]
52        #[schema(min_length = 1, max_length = 64)]
53        #[serde(default)]
54        source: Option<compact_str::CompactString>,
55        #[garde(
56            length(chars, min = 1, max = 255),
57            custom(crate::utils::validate_json_path)
58        )]
59        #[schema(min_length = 1, max_length = 255)]
60        path: String,
61        #[garde(length(max = 255))]
62        #[schema(max_length = 255)]
63        equals: compact_str::CompactString,
64    },
65    FieldContains {
66        #[garde(length(chars, min = 1, max = 64))]
67        #[schema(min_length = 1, max_length = 64)]
68        #[serde(default)]
69        source: Option<compact_str::CompactString>,
70        #[garde(
71            length(chars, min = 1, max = 255),
72            custom(crate::utils::validate_json_path)
73        )]
74        #[schema(min_length = 1, max_length = 255)]
75        path: String,
76        #[garde(length(max = 255))]
77        #[schema(max_length = 255)]
78        contains: compact_str::CompactString,
79    },
80    FieldStartsWith {
81        #[garde(length(chars, min = 1, max = 64))]
82        #[schema(min_length = 1, max_length = 64)]
83        #[serde(default)]
84        source: Option<compact_str::CompactString>,
85        #[garde(
86            length(chars, min = 1, max = 255),
87            custom(crate::utils::validate_json_path)
88        )]
89        #[schema(min_length = 1, max_length = 255)]
90        path: String,
91        #[garde(length(max = 255))]
92        #[schema(max_length = 255)]
93        starts_with: compact_str::CompactString,
94    },
95    FieldEndsWith {
96        #[garde(length(chars, min = 1, max = 64))]
97        #[schema(min_length = 1, max_length = 64)]
98        #[serde(default)]
99        source: Option<compact_str::CompactString>,
100        #[garde(
101            length(chars, min = 1, max = 255),
102            custom(crate::utils::validate_json_path)
103        )]
104        #[schema(min_length = 1, max_length = 255)]
105        path: String,
106        #[garde(length(max = 255))]
107        #[schema(max_length = 255)]
108        ends_with: compact_str::CompactString,
109    },
110}
111
112impl OAuthProviderMappingMatcher {
113    pub const MAX_NESTING_DEPTH: usize = 3;
114
115    fn nested_within_limit(&self, depth: usize) -> bool {
116        match self {
117            OAuthProviderMappingMatcher::And { matchers }
118            | OAuthProviderMappingMatcher::Or { matchers } => {
119                depth < Self::MAX_NESTING_DEPTH
120                    && matchers.iter().all(|m| m.nested_within_limit(depth + 1))
121            }
122            OAuthProviderMappingMatcher::Not { matcher } => {
123                depth < Self::MAX_NESTING_DEPTH && matcher.nested_within_limit(depth + 1)
124            }
125            _ => true,
126        }
127    }
128
129    pub fn validate_nesting(value: &Self, _context: &()) -> garde::Result {
130        if value.nested_within_limit(0) {
131            Ok(())
132        } else {
133            Err(garde::Error::new(format!(
134                "matcher may not nest groups more than {} levels deep",
135                Self::MAX_NESTING_DEPTH
136            )))
137        }
138    }
139
140    pub fn validate_optional_nesting(value: &Option<Self>, context: &()) -> garde::Result {
141        match value {
142            Some(matcher) => Self::validate_nesting(matcher, context),
143            None => Ok(()),
144        }
145    }
146
147    fn query_nodes<'a>(
148        info: &'a super::oauth_provider::OAuthProviderInfo,
149        source: Option<&str>,
150        path: &str,
151    ) -> Vec<&'a serde_json::Value> {
152        let Some(value) = info.source(source) else {
153            return Vec::new();
154        };
155
156        match serde_json_path::JsonPath::parse(path) {
157            Ok(path) => path.query(value).all(),
158            Err(err) => {
159                tracing::warn!(
160                    path,
161                    "failed to parse oauth provider mapping matcher path: {err}"
162                );
163
164                Vec::new()
165            }
166        }
167    }
168
169    fn node_equals_str(node: &serde_json::Value, expected: &str) -> bool {
170        match node {
171            serde_json::Value::String(value) => value == expected,
172            node => node.to_compact_string() == expected,
173        }
174    }
175
176    fn node_string(node: &serde_json::Value) -> compact_str::CompactString {
177        match node {
178            serde_json::Value::String(value) => value.to_compact_string(),
179            node => node.to_compact_string(),
180        }
181    }
182
183    pub fn matches(
184        &self,
185        granted_scopes: &[compact_str::CompactString],
186        info: &super::oauth_provider::OAuthProviderInfo,
187    ) -> bool {
188        match self {
189            OAuthProviderMappingMatcher::None => true,
190            OAuthProviderMappingMatcher::Never => false,
191            OAuthProviderMappingMatcher::And { matchers } => {
192                matchers.iter().all(|m| m.matches(granted_scopes, info))
193            }
194            OAuthProviderMappingMatcher::Or { matchers } => {
195                matchers.iter().any(|m| m.matches(granted_scopes, info))
196            }
197            OAuthProviderMappingMatcher::Not { matcher } => !matcher.matches(granted_scopes, info),
198            OAuthProviderMappingMatcher::Scopes { scopes } => {
199                scopes.iter().all(|scope| granted_scopes.contains(scope))
200            }
201            OAuthProviderMappingMatcher::FieldExists { source, path } => {
202                !Self::query_nodes(info, source.as_deref(), path).is_empty()
203            }
204            OAuthProviderMappingMatcher::FieldEquals {
205                source,
206                path,
207                equals,
208            } => Self::query_nodes(info, source.as_deref(), path)
209                .into_iter()
210                .any(|node| Self::node_equals_str(node, equals)),
211            OAuthProviderMappingMatcher::FieldContains {
212                source,
213                path,
214                contains,
215            } => Self::query_nodes(info, source.as_deref(), path)
216                .into_iter()
217                .any(|node| match node {
218                    serde_json::Value::Array(values) => values
219                        .iter()
220                        .any(|value| Self::node_equals_str(value, contains)),
221                    node => Self::node_string(node).contains(contains.as_str()),
222                }),
223            OAuthProviderMappingMatcher::FieldStartsWith {
224                source,
225                path,
226                starts_with,
227            } => Self::query_nodes(info, source.as_deref(), path)
228                .into_iter()
229                .any(|node| Self::node_string(node).starts_with(starts_with.as_str())),
230            OAuthProviderMappingMatcher::FieldEndsWith {
231                source,
232                path,
233                ends_with,
234            } => Self::query_nodes(info, source.as_deref(), path)
235                .into_iter()
236                .any(|node| Self::node_string(node).ends_with(ends_with.as_str())),
237        }
238    }
239}
240
241#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
242#[serde(tag = "type", rename_all = "snake_case")]
243pub enum OAuthProviderMappingType {
244    Role {
245        #[garde(skip)]
246        role_uuid: uuid::Uuid,
247        #[garde(skip)]
248        #[serde(default)]
249        revoke_unmatched: bool,
250    },
251    ServerSubuser {
252        #[garde(skip)]
253        server_uuid: uuid::Uuid,
254        #[garde(custom(crate::permissions::validate_server_permissions))]
255        permissions: Vec<compact_str::CompactString>,
256        #[garde(skip)]
257        ignored_files: Vec<compact_str::CompactString>,
258        #[garde(skip)]
259        #[serde(default)]
260        revoke_unmatched: bool,
261    },
262}
263
264impl OAuthProviderMappingType {
265    /// Whether `permissions` holds every permission this mapping hands out, so a delegated
266    /// operator cannot grant more through a provider than they have themselves. A mapping to a
267    /// role that no longer exists grants nothing.
268    pub async fn is_within_permissions_of(
269        &self,
270        database: &crate::database::Database,
271        permissions: &super::user::PermissionManager,
272    ) -> Result<bool, crate::database::DatabaseError> {
273        Ok(match self {
274            OAuthProviderMappingType::Role { role_uuid, .. } => {
275                match super::role::Role::by_uuid_optional(database, *role_uuid).await? {
276                    Some(role) => {
277                        permissions.can_grant(&role.admin_permissions, &role.server_permissions)
278                    }
279                    None => true,
280                }
281            }
282            OAuthProviderMappingType::ServerSubuser {
283                permissions: subuser_permissions,
284                ..
285            } => permissions.can_grant(&[], subuser_permissions),
286        })
287    }
288
289    pub fn revoke_unmatched(&self) -> bool {
290        match self {
291            OAuthProviderMappingType::Role {
292                revoke_unmatched, ..
293            }
294            | OAuthProviderMappingType::ServerSubuser {
295                revoke_unmatched, ..
296            } => *revoke_unmatched,
297        }
298    }
299}
300
301#[derive(Serialize, Deserialize, Clone)]
302pub struct OAuthProviderMapping {
303    pub uuid: uuid::Uuid,
304    pub oauth_provider: Fetchable<super::oauth_provider::OAuthProvider>,
305
306    pub matcher: OAuthProviderMappingMatcher,
307    pub mapping: OAuthProviderMappingType,
308
309    pub created: chrono::NaiveDateTime,
310
311    extension_data: super::ModelExtensionData,
312}
313
314impl BaseModel for OAuthProviderMapping {
315    const NAME: &'static str = "oauth_provider_mapping";
316
317    fn get_extension_list() -> &'static super::ModelExtensionList {
318        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
319            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
320
321        &EXTENSIONS
322    }
323
324    fn get_extension_data(&self) -> &super::ModelExtensionData {
325        &self.extension_data
326    }
327
328    #[inline]
329    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
330        let prefix = prefix.unwrap_or_default();
331
332        BTreeMap::from([
333            (
334                "oauth_provider_mappings.uuid",
335                compact_str::format_compact!("{prefix}uuid"),
336            ),
337            (
338                "oauth_provider_mappings.oauth_provider_uuid",
339                compact_str::format_compact!("{prefix}oauth_provider_uuid"),
340            ),
341            (
342                "oauth_provider_mappings.matcher",
343                compact_str::format_compact!("{prefix}matcher"),
344            ),
345            (
346                "oauth_provider_mappings.mapping",
347                compact_str::format_compact!("{prefix}mapping"),
348            ),
349            (
350                "oauth_provider_mappings.created",
351                compact_str::format_compact!("{prefix}created"),
352            ),
353        ])
354    }
355
356    #[inline]
357    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
358        let prefix = prefix.unwrap_or_default();
359
360        Ok(Self {
361            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
362            oauth_provider: super::oauth_provider::OAuthProvider::get_fetchable(
363                row.try_get(compact_str::format_compact!("{prefix}oauth_provider_uuid").as_str())?,
364            ),
365            matcher: serde_json::from_value(
366                row.try_get(compact_str::format_compact!("{prefix}matcher").as_str())?,
367            )?,
368            mapping: serde_json::from_value(
369                row.try_get(compact_str::format_compact!("{prefix}mapping").as_str())?,
370            )?,
371            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
372            extension_data: Self::map_extensions(prefix, row)?,
373        })
374    }
375}
376
377impl OAuthProviderMapping {
378    pub async fn by_oauth_provider_uuid_uuid(
379        database: &crate::database::Database,
380        oauth_provider_uuid: uuid::Uuid,
381        uuid: uuid::Uuid,
382    ) -> Result<Option<Self>, crate::database::DatabaseError> {
383        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
384            r#"
385            SELECT {}
386            FROM oauth_provider_mappings
387            WHERE oauth_provider_mappings.oauth_provider_uuid = $1 AND oauth_provider_mappings.uuid = $2
388            "#,
389            Self::columns_sql(None)
390        )))
391        .bind(oauth_provider_uuid)
392        .bind(uuid)
393        .fetch_optional(database.read())
394        .await?;
395
396        row.try_map(|row| Self::map(None, &row))
397    }
398
399    pub async fn by_oauth_provider_uuid_with_pagination(
400        database: &crate::database::Database,
401        oauth_provider_uuid: uuid::Uuid,
402        page: i64,
403        per_page: i64,
404    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
405        super::PaginatedQueryBuilder::new(
406            Self::columns_sql(None),
407            r#"
408            FROM oauth_provider_mappings
409            WHERE oauth_provider_mappings.oauth_provider_uuid = $1
410            "#,
411            "oauth_provider_mappings.created, oauth_provider_mappings.uuid",
412        )
413        .bind(oauth_provider_uuid)
414        .fetch(database, page, per_page)
415        .await
416    }
417
418    pub async fn all_by_oauth_provider_uuid(
419        database: &crate::database::Database,
420        oauth_provider_uuid: uuid::Uuid,
421    ) -> Result<Vec<Self>, crate::database::DatabaseError> {
422        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
423            r#"
424            SELECT {}
425            FROM oauth_provider_mappings
426            WHERE oauth_provider_mappings.oauth_provider_uuid = $1
427            ORDER BY oauth_provider_mappings.created, oauth_provider_mappings.uuid
428            "#,
429            Self::columns_sql(None)
430        )))
431        .bind(oauth_provider_uuid)
432        .fetch_all(database.read())
433        .await?;
434
435        rows.into_iter()
436            .map(|row| Self::map(None, &row))
437            .try_collect_vec()
438    }
439
440    pub async fn apply_for_user(
441        state: &crate::State,
442        oauth_provider_uuid: uuid::Uuid,
443        user_uuid: uuid::Uuid,
444        granted_scopes: &[compact_str::CompactString],
445        info: &super::oauth_provider::OAuthProviderInfo,
446    ) -> Result<(), crate::database::DatabaseError> {
447        let mappings =
448            Self::all_by_oauth_provider_uuid(&state.database, oauth_provider_uuid).await?;
449        let (matched, unmatched): (Vec<Self>, Vec<Self>) = mappings
450            .into_iter()
451            .partition(|mapping| mapping.matcher.matches(granted_scopes, info));
452        if matched.is_empty() && !unmatched.iter().any(|m| m.mapping.revoke_unmatched()) {
453            return Ok(());
454        }
455
456        let mut user = match super::user::User::by_uuid_optional(&state.database, user_uuid).await?
457        {
458            Some(user) => user,
459            None => return Ok(()),
460        };
461
462        let matched_role_uuids = matched
463            .iter()
464            .filter_map(|mapping| match &mapping.mapping {
465                OAuthProviderMappingType::Role { role_uuid, .. } => Some(*role_uuid),
466                _ => None,
467            })
468            .collect::<Vec<_>>();
469        let matched_server_uuids = matched
470            .iter()
471            .filter_map(|mapping| match &mapping.mapping {
472                OAuthProviderMappingType::ServerSubuser { server_uuid, .. } => Some(*server_uuid),
473                _ => None,
474            })
475            .collect::<Vec<_>>();
476
477        for mapping in matched {
478            if let Err(err) = mapping.apply(state, &mut user).await {
479                tracing::warn!(
480                    mapping = %mapping.uuid,
481                    user = %user.uuid,
482                    "failed to apply oauth provider mapping: {:#?}",
483                    err
484                );
485            }
486        }
487
488        for mapping in unmatched {
489            if !mapping.mapping.revoke_unmatched() {
490                continue;
491            }
492
493            match &mapping.mapping {
494                OAuthProviderMappingType::Role { role_uuid, .. }
495                    if matched_role_uuids.contains(role_uuid) =>
496                {
497                    continue;
498                }
499                OAuthProviderMappingType::ServerSubuser { server_uuid, .. }
500                    if matched_server_uuids.contains(server_uuid) =>
501                {
502                    continue;
503                }
504                _ => {}
505            }
506
507            if let Err(err) = mapping.revert(state, &mut user).await {
508                tracing::warn!(
509                    mapping = %mapping.uuid,
510                    user = %user.uuid,
511                    "failed to revert oauth provider mapping: {:#?}",
512                    err
513                );
514            }
515        }
516
517        Ok(())
518    }
519
520    async fn apply(
521        &self,
522        state: &crate::State,
523        user: &mut super::user::User,
524    ) -> Result<(), crate::database::DatabaseError> {
525        match &self.mapping {
526            OAuthProviderMappingType::Role { role_uuid, .. } => {
527                if super::role::Role::by_uuid_optional_cached(&state.database, *role_uuid)
528                    .await?
529                    .is_none()
530                {
531                    return Ok(());
532                }
533
534                user.update(
535                    state,
536                    super::user::UpdateUserOptions {
537                        role_uuid: Some(Some(*role_uuid)),
538                        ..Default::default()
539                    },
540                )
541                .await
542            }
543            OAuthProviderMappingType::ServerSubuser {
544                server_uuid,
545                permissions,
546                ignored_files,
547                ..
548            } => {
549                let server = match super::server::Server::by_uuid_optional_cached(
550                    &state.database,
551                    *server_uuid,
552                )
553                .await?
554                {
555                    Some(server) => server,
556                    None => return Ok(()),
557                };
558
559                if server.owner.uuid == user.uuid {
560                    return Ok(());
561                }
562
563                if let Some(mut subuser) =
564                    super::server_subuser::ServerSubuser::by_server_uuid_user_uuid(
565                        &state.database,
566                        server.uuid,
567                        user.uuid,
568                    )
569                    .await?
570                {
571                    subuser
572                        .update(
573                            state,
574                            super::server_subuser::UpdateServerSubuserOptions {
575                                permissions: Some(permissions.clone()),
576                                ignored_files: Some(ignored_files.clone()),
577                            },
578                        )
579                        .await?;
580                } else {
581                    super::server_subuser::ServerSubuser::create(
582                        state,
583                        super::server_subuser::CreateServerSubuserOptions {
584                            server: &server,
585                            email: user.email.clone(),
586                            permissions: permissions.clone(),
587                            ignored_files: ignored_files.clone(),
588                        },
589                    )
590                    .await?;
591                }
592
593                Ok(())
594            }
595        }
596    }
597
598    async fn revert(
599        &self,
600        state: &crate::State,
601        user: &mut super::user::User,
602    ) -> Result<(), crate::database::DatabaseError> {
603        match &self.mapping {
604            OAuthProviderMappingType::Role { role_uuid, .. } => {
605                if !user
606                    .role
607                    .as_ref()
608                    .is_some_and(|role| role.uuid == *role_uuid)
609                {
610                    return Ok(());
611                }
612
613                user.update(
614                    state,
615                    super::user::UpdateUserOptions {
616                        role_uuid: Some(None),
617                        ..Default::default()
618                    },
619                )
620                .await
621            }
622            OAuthProviderMappingType::ServerSubuser { server_uuid, .. } => {
623                let server = match super::server::Server::by_uuid_optional_cached(
624                    &state.database,
625                    *server_uuid,
626                )
627                .await?
628                {
629                    Some(server) => server,
630                    None => return Ok(()),
631                };
632
633                if server.owner.uuid == user.uuid {
634                    return Ok(());
635                }
636
637                if let Some(subuser) =
638                    super::server_subuser::ServerSubuser::by_server_uuid_user_uuid(
639                        &state.database,
640                        server.uuid,
641                        user.uuid,
642                    )
643                    .await?
644                {
645                    subuser.delete(state, ()).await?;
646                }
647
648                Ok(())
649            }
650        }
651    }
652
653    pub async fn cleanup_uuid_arrays(
654        database: &crate::database::Database,
655    ) -> Result<u64, crate::database::DatabaseError> {
656        let result = sqlx::query(
657            "DELETE FROM oauth_provider_mappings
658            WHERE (
659                mapping->>'type' = 'role'
660                AND NOT EXISTS (SELECT 1 FROM roles WHERE uuid = (mapping->>'role_uuid')::uuid)
661            ) OR (
662                mapping->>'type' = 'server_subuser'
663                AND NOT EXISTS (SELECT 1 FROM servers WHERE uuid = (mapping->>'server_uuid')::uuid)
664            )",
665        )
666        .execute(database.write())
667        .await?;
668
669        Ok(result.rows_affected())
670    }
671}
672
673#[async_trait::async_trait]
674impl IntoAdminApiObject for OAuthProviderMapping {
675    type AdminApiObject = AdminApiOAuthProviderMapping;
676    type ExtraArgs<'a> = ();
677
678    async fn into_admin_api_object<'a>(
679        self,
680        state: &crate::State,
681        _args: Self::ExtraArgs<'a>,
682    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
683        let api_object = AdminApiOAuthProviderMapping::init_hooks(&self, state).await?;
684
685        let api_object = finish_extendible!(
686            AdminApiOAuthProviderMapping {
687                uuid: self.uuid,
688                matcher: self.matcher,
689                mapping: self.mapping,
690                created: self.created.and_utc(),
691            },
692            api_object,
693            state
694        )?;
695
696        Ok(api_object)
697    }
698}
699
700#[derive(ToSchema, Deserialize, Validate)]
701pub struct CreateOAuthProviderMappingOptions {
702    #[garde(skip)]
703    pub oauth_provider_uuid: uuid::Uuid,
704
705    #[garde(dive, custom(OAuthProviderMappingMatcher::validate_nesting))]
706    pub matcher: OAuthProviderMappingMatcher,
707    #[garde(dive)]
708    pub mapping: OAuthProviderMappingType,
709}
710
711#[async_trait::async_trait]
712impl CreatableModel for OAuthProviderMapping {
713    type CreateOptions<'a> = CreateOAuthProviderMappingOptions;
714    type CreateResult = Self;
715
716    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
717        static CREATE_LISTENERS: LazyLock<CreateListenerList<OAuthProviderMapping>> =
718            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
719
720        &CREATE_LISTENERS
721    }
722
723    async fn create_with_transaction(
724        state: &crate::State,
725        mut options: Self::CreateOptions<'_>,
726        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
727    ) -> Result<Self, crate::database::DatabaseError> {
728        options.validate()?;
729
730        super::oauth_provider::OAuthProvider::by_uuid_optional_cached(
731            &state.database,
732            options.oauth_provider_uuid,
733        )
734        .await?
735        .ok_or(crate::database::InvalidRelationError("oauth_provider"))?;
736
737        let mut query_builder = InsertQueryBuilder::new("oauth_provider_mappings");
738
739        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
740
741        query_builder
742            .set("oauth_provider_uuid", options.oauth_provider_uuid)
743            .set("matcher", serde_json::to_value(&options.matcher)?)
744            .set("mapping", serde_json::to_value(&options.mapping)?);
745
746        let row = query_builder
747            .returning(&Self::columns_sql(None))
748            .fetch_one(&mut **transaction)
749            .await?;
750        let mut mapping = Self::map(None, &row)?;
751
752        Self::run_after_create_handlers(&mut mapping, &options, state, transaction).await?;
753
754        Ok(mapping)
755    }
756}
757
758#[derive(ToSchema, Serialize, Deserialize, Validate, Default)]
759pub struct UpdateOAuthProviderMappingOptions {
760    #[garde(dive, custom(OAuthProviderMappingMatcher::validate_optional_nesting))]
761    pub matcher: Option<OAuthProviderMappingMatcher>,
762    #[garde(dive)]
763    pub mapping: Option<OAuthProviderMappingType>,
764}
765
766#[async_trait::async_trait]
767impl UpdatableModel for OAuthProviderMapping {
768    type UpdateOptions = UpdateOAuthProviderMappingOptions;
769
770    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
771        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<OAuthProviderMapping>> =
772            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
773
774        &UPDATE_LISTENERS
775    }
776
777    async fn update_with_transaction(
778        &mut self,
779        state: &crate::State,
780        mut options: Self::UpdateOptions,
781        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
782    ) -> Result<(), crate::database::DatabaseError> {
783        options.validate()?;
784
785        let mut query_builder = UpdateQueryBuilder::new("oauth_provider_mappings");
786
787        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
788            .await?;
789
790        let matcher = options
791            .matcher
792            .as_ref()
793            .map(serde_json::to_value)
794            .transpose()?;
795        let mapping = options
796            .mapping
797            .as_ref()
798            .map(serde_json::to_value)
799            .transpose()?;
800
801        query_builder
802            .set("matcher", matcher)
803            .set("mapping", mapping)
804            .where_eq("uuid", self.uuid);
805
806        query_builder.execute(&mut **transaction).await?;
807
808        if let Some(matcher) = options.matcher {
809            self.matcher = matcher;
810        }
811        if let Some(mapping) = options.mapping {
812            self.mapping = mapping;
813        }
814
815        self.run_after_update_handlers(state, transaction).await?;
816
817        Ok(())
818    }
819}
820
821#[async_trait::async_trait]
822impl DeletableModel for OAuthProviderMapping {
823    type DeleteOptions = ();
824
825    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
826        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<OAuthProviderMapping>> =
827            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
828
829        &DELETE_LISTENERS
830    }
831
832    async fn delete_with_transaction(
833        &self,
834        state: &crate::State,
835        options: Self::DeleteOptions,
836        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
837    ) -> Result<(), anyhow::Error> {
838        self.run_delete_handlers(&options, state, transaction)
839            .await?;
840
841        sqlx::query(
842            r#"
843            DELETE FROM oauth_provider_mappings
844            WHERE oauth_provider_mappings.uuid = $1
845            "#,
846        )
847        .bind(self.uuid)
848        .execute(&mut **transaction)
849        .await?;
850
851        self.run_after_delete_handlers(&options, state, transaction)
852            .await?;
853
854        Ok(())
855    }
856}
857
858#[schema_extension_derive::extendible]
859#[init_args(OAuthProviderMapping, crate::State)]
860#[hook_args(crate::State)]
861#[derive(ToSchema, Serialize)]
862#[schema(title = "AdminOAuthProviderMapping")]
863pub struct AdminApiOAuthProviderMapping {
864    pub uuid: uuid::Uuid,
865
866    pub matcher: OAuthProviderMappingMatcher,
867    pub mapping: OAuthProviderMappingType,
868
869    pub created: chrono::DateTime<chrono::Utc>,
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use crate::models::oauth_provider::OAuthProviderInfo;
876
877    fn provider_info() -> OAuthProviderInfo {
878        OAuthProviderInfo {
879            info: serde_json::json!({ "id": "1", "username": "alice" }),
880            sources: [
881                (
882                    "member".into(),
883                    serde_json::json!({ "roles": ["111", "222"] }),
884                ),
885                (
886                    "extra".into(),
887                    serde_json::json!({ "only_in_source": "yes", "id": "2" }),
888                ),
889            ]
890            .into_iter()
891            .collect(),
892        }
893    }
894
895    // OAuthProviderMappingMatcher::matches
896    #[test]
897    fn no_source_evaluates_against_main_info_only() {
898        let info = provider_info();
899
900        assert!(
901            OAuthProviderMappingMatcher::FieldEquals {
902                source: None,
903                path: "$.id".into(),
904                equals: "1".into(),
905            }
906            .matches(&[], &info)
907        );
908        assert!(
909            !OAuthProviderMappingMatcher::FieldExists {
910                source: None,
911                path: "$.only_in_source".into(),
912            }
913            .matches(&[], &info)
914        );
915        assert!(
916            !OAuthProviderMappingMatcher::FieldContains {
917                source: None,
918                path: "$.roles".into(),
919                contains: "222".into(),
920            }
921            .matches(&[], &info)
922        );
923    }
924
925    #[test]
926    fn none_always_matches_and_never_never_does() {
927        let info = provider_info();
928
929        assert!(OAuthProviderMappingMatcher::None.matches(&[], &info));
930        assert!(!OAuthProviderMappingMatcher::Never.matches(&[], &info));
931    }
932
933    #[test]
934    fn named_source_evaluates_against_that_source_only() {
935        let info = provider_info();
936
937        assert!(
938            OAuthProviderMappingMatcher::FieldExists {
939                source: Some("extra".into()),
940                path: "$.only_in_source".into(),
941            }
942            .matches(&[], &info)
943        );
944        assert!(
945            OAuthProviderMappingMatcher::FieldEquals {
946                source: Some("extra".into()),
947                path: "$.id".into(),
948                equals: "2".into(),
949            }
950            .matches(&[], &info)
951        );
952        assert!(
953            !OAuthProviderMappingMatcher::FieldEquals {
954                source: Some("extra".into()),
955                path: "$.id".into(),
956                equals: "1".into(),
957            }
958            .matches(&[], &info)
959        );
960        assert!(
961            !OAuthProviderMappingMatcher::FieldExists {
962                source: Some("extra".into()),
963                path: "$.username".into(),
964            }
965            .matches(&[], &info)
966        );
967    }
968
969    #[test]
970    fn missing_source_never_matches_and_its_negation_does() {
971        let info = provider_info();
972
973        let exists_root = OAuthProviderMappingMatcher::FieldExists {
974            source: Some("guild".into()),
975            path: "$".into(),
976        };
977        assert!(!exists_root.matches(&[], &info));
978        assert!(
979            OAuthProviderMappingMatcher::Not {
980                matcher: Box::new(exists_root),
981            }
982            .matches(&[], &info)
983        );
984        assert!(
985            !OAuthProviderMappingMatcher::FieldEquals {
986                source: Some("guild".into()),
987                path: "$.id".into(),
988                equals: "1".into(),
989            }
990            .matches(&[], &info)
991        );
992        assert!(
993            !OAuthProviderMappingMatcher::FieldStartsWith {
994                source: Some("guild".into()),
995                path: "$.username".into(),
996                starts_with: "a".into(),
997            }
998            .matches(&[], &info)
999        );
1000        assert!(
1001            !OAuthProviderMappingMatcher::FieldEndsWith {
1002                source: Some("guild".into()),
1003                path: "$.username".into(),
1004                ends_with: "e".into(),
1005            }
1006            .matches(&[], &info)
1007        );
1008    }
1009
1010    #[test]
1011    fn discord_member_roles_match_by_contained_role_id() {
1012        let info = provider_info();
1013
1014        assert!(
1015            OAuthProviderMappingMatcher::FieldContains {
1016                source: Some("member".into()),
1017                path: "$.roles".into(),
1018                contains: "222".into(),
1019            }
1020            .matches(&[], &info)
1021        );
1022        assert!(
1023            !OAuthProviderMappingMatcher::FieldContains {
1024                source: Some("member".into()),
1025                path: "$.roles".into(),
1026                contains: "333".into(),
1027            }
1028            .matches(&[], &info)
1029        );
1030    }
1031
1032    #[test]
1033    fn stored_matcher_without_source_key_evaluates_against_main_info() {
1034        let info = provider_info();
1035
1036        let matches_main: OAuthProviderMappingMatcher =
1037            serde_json::from_str(r#"{"type":"field_equals","path":"$.id","equals":"1"}"#).unwrap();
1038        let matches_source: OAuthProviderMappingMatcher =
1039            serde_json::from_str(r#"{"type":"field_equals","path":"$.id","equals":"2"}"#).unwrap();
1040
1041        assert!(matches_main.matches(&[], &info));
1042        assert!(!matches_source.matches(&[], &info));
1043    }
1044}