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 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 #[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}