Skip to main content

shared/models/
server_subuser.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use garde::Validate;
6use rand::distr::SampleString;
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(Clone, Serialize, Deserialize)]
16pub struct ServerSubuser {
17    pub user: super::user::User,
18    pub server: Fetchable<super::server::Server>,
19
20    pub permissions: Vec<compact_str::CompactString>,
21    pub ignored_files: Vec<compact_str::CompactString>,
22
23    pub created: chrono::NaiveDateTime,
24
25    extension_data: super::ModelExtensionData,
26}
27
28impl BaseModel for ServerSubuser {
29    const NAME: &'static str = "server_subuser";
30
31    fn get_extension_list() -> &'static super::ModelExtensionList {
32        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
33            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
34
35        &EXTENSIONS
36    }
37
38    fn get_extension_data(&self) -> &super::ModelExtensionData {
39        &self.extension_data
40    }
41
42    #[inline]
43    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
44        let prefix = prefix.unwrap_or_default();
45
46        let mut columns = BTreeMap::from([
47            (
48                "server_subusers.server_uuid",
49                compact_str::format_compact!("{prefix}server_uuid"),
50            ),
51            (
52                "server_subusers.permissions",
53                compact_str::format_compact!("{prefix}permissions"),
54            ),
55            (
56                "server_subusers.ignored_files",
57                compact_str::format_compact!("{prefix}ignored_files"),
58            ),
59            (
60                "server_subusers.created",
61                compact_str::format_compact!("{prefix}created"),
62            ),
63        ]);
64
65        columns.extend(super::user::User::base_columns(Some("user_")));
66
67        columns
68    }
69
70    #[inline]
71    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
72        let prefix = prefix.unwrap_or_default();
73
74        Ok(Self {
75            user: super::user::User::map(Some("user_"), row)?,
76            server: super::server::Server::get_fetchable(
77                row.try_get(compact_str::format_compact!("{prefix}server_uuid").as_str())?,
78            ),
79            permissions: row
80                .try_get(compact_str::format_compact!("{prefix}permissions").as_str())?,
81            ignored_files: row
82                .try_get(compact_str::format_compact!("{prefix}ignored_files").as_str())?,
83            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
84            extension_data: Self::map_extensions(prefix, row)?,
85        })
86    }
87
88    fn cache_invalidation_keys(&self) -> Vec<compact_str::CompactString> {
89        vec![Self::permissions_cache_key(
90            self.server.uuid,
91            self.user.uuid,
92        )]
93    }
94}
95
96impl ServerSubuser {
97    async fn invalidate_wings_permissions(&self, database: &Arc<crate::database::Database>) {
98        let node = async {
99            self.server
100                .fetch_cached(database)
101                .await?
102                .node
103                .fetch_cached(database)
104                .await
105        }
106        .await;
107
108        match node {
109            Ok(node) => node.invalidate_permissions(
110                database,
111                super::node::PermissionsInvalidation {
112                    users: vec![self.user.uuid],
113                    ..Default::default()
114                },
115            ),
116            Err(err) => {
117                tracing::error!(server = %self.server.uuid, "failed to invalidate permissions in wings: {:?}", err);
118            }
119        }
120    }
121
122    fn permissions_cache_key(
123        server_uuid: uuid::Uuid,
124        user_uuid: uuid::Uuid,
125    ) -> compact_str::CompactString {
126        compact_str::format_compact!("{}::{server_uuid}::{user_uuid}", Self::NAME)
127    }
128
129    pub async fn permissions_by_server_uuid_user_uuid_cached(
130        database: &crate::database::Database,
131        server_uuid: uuid::Uuid,
132        user_uuid: uuid::Uuid,
133    ) -> Result<
134        Option<(
135            Vec<compact_str::CompactString>,
136            Vec<compact_str::CompactString>,
137        )>,
138        anyhow::Error,
139    > {
140        database
141            .cache
142            .cached(
143                &Self::permissions_cache_key(server_uuid, user_uuid),
144                super::BY_UUID_CACHE_TTL,
145                || Self::permissions_by_server_uuid_user_uuid(database, server_uuid, user_uuid),
146            )
147            .await
148    }
149
150    pub async fn permissions_by_server_uuid_user_uuid(
151        database: &crate::database::Database,
152        server_uuid: uuid::Uuid,
153        user_uuid: uuid::Uuid,
154    ) -> Result<
155        Option<(
156            Vec<compact_str::CompactString>,
157            Vec<compact_str::CompactString>,
158        )>,
159        sqlx::Error,
160    > {
161        let row = sqlx::query(
162            r#"
163            SELECT server_subusers.permissions, server_subusers.ignored_files
164            FROM server_subusers
165            WHERE server_subusers.server_uuid = $1 AND server_subusers.user_uuid = $2
166            "#,
167        )
168        .bind(server_uuid)
169        .bind(user_uuid)
170        .fetch_optional(database.read())
171        .await?;
172
173        row.map(|row| Ok((row.try_get("permissions")?, row.try_get("ignored_files")?)))
174            .transpose()
175    }
176
177    pub async fn by_server_uuid_user_uuid(
178        database: &crate::database::Database,
179        server_uuid: uuid::Uuid,
180        user_uuid: uuid::Uuid,
181    ) -> Result<Option<Self>, crate::database::DatabaseError> {
182        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
183            r#"
184            SELECT {}
185            FROM server_subusers
186            JOIN users ON users.uuid = server_subusers.user_uuid
187            LEFT JOIN roles ON roles.uuid = users.role_uuid
188            WHERE server_subusers.server_uuid = $1 AND server_subusers.user_uuid = $2
189            "#,
190            Self::columns_sql(None)
191        )))
192        .bind(server_uuid)
193        .bind(user_uuid)
194        .fetch_optional(database.read())
195        .await?;
196
197        row.try_map(|row| Self::map(None, &row))
198    }
199
200    pub async fn by_server_uuid_with_pagination(
201        database: &crate::database::Database,
202        server_uuid: uuid::Uuid,
203        page: i64,
204        per_page: i64,
205        search: Option<&str>,
206    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
207        super::PaginatedQueryBuilder::new(
208            Self::columns_sql(None),
209            format!(
210                r#"
211                FROM server_subusers
212                JOIN users ON users.uuid = server_subusers.user_uuid
213                LEFT JOIN roles ON roles.uuid = users.role_uuid
214                WHERE server_subusers.server_uuid = $1 AND {search}
215                "#,
216                search = super::search_sql(2, &["users.username"], &["users.uuid"]),
217            ),
218            "server_subusers.created, server_subusers.server_uuid, server_subusers.user_uuid",
219        )
220        .bind(server_uuid)
221        .bind(search)
222        .fetch(database, page, per_page)
223        .await
224    }
225
226    pub async fn count_by_server_uuid(
227        database: &crate::database::Database,
228        server_uuid: uuid::Uuid,
229    ) -> Result<i64, sqlx::Error> {
230        sqlx::query_scalar(
231            r#"
232            SELECT COUNT(*)
233            FROM server_subusers
234            WHERE server_subusers.server_uuid = $1
235            "#,
236        )
237        .bind(server_uuid)
238        .fetch_one(database.read())
239        .await
240    }
241}
242
243#[async_trait::async_trait]
244impl IntoApiObject for ServerSubuser {
245    type ApiObject = ApiServerSubuser;
246    type ExtraArgs<'a> = &'a crate::storage::StorageUrlRetriever<'a>;
247
248    async fn into_api_object<'a>(
249        self,
250        state: &crate::State,
251        storage_url_retriever: Self::ExtraArgs<'a>,
252    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
253        let api_object = ApiServerSubuser::init_hooks(&self, state).await?;
254
255        let api_object = finish_extendible!(
256            ApiServerSubuser {
257                user: self
258                    .user
259                    .into_api_object(state, storage_url_retriever)
260                    .await?,
261                permissions: self.permissions,
262                ignored_files: self.ignored_files,
263                created: self.created.and_utc(),
264            },
265            api_object,
266            state
267        )?;
268
269        Ok(api_object)
270    }
271}
272
273#[derive(Validate)]
274pub struct CreateServerSubuserOptions<'a> {
275    #[garde(skip)]
276    pub server: &'a super::server::Server,
277
278    #[garde(email)]
279    pub email: compact_str::CompactString,
280    #[garde(custom(crate::permissions::validate_server_permissions))]
281    pub permissions: Vec<compact_str::CompactString>,
282    #[garde(
283        length(max = 1024),
284        inner(length(chars, min = 1, max = 255)),
285        custom(crate::utils::validate_ignored_files)
286    )]
287    pub ignored_files: Vec<compact_str::CompactString>,
288}
289
290#[async_trait::async_trait]
291impl CreatableModel for ServerSubuser {
292    type CreateOptions<'a> = CreateServerSubuserOptions<'a>;
293    type CreateResult = Self;
294
295    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
296        static CREATE_LISTENERS: LazyLock<CreateListenerList<ServerSubuser>> =
297            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
298
299        &CREATE_LISTENERS
300    }
301
302    async fn create(
303        state: &crate::State,
304        options: Self::CreateOptions<'_>,
305    ) -> Result<Self, crate::database::DatabaseError> {
306        let mut transaction = state.database.write().begin().await?;
307
308        let result = match Self::create_with_transaction(state, options, &mut transaction).await {
309            Ok(result) => result,
310            Err(err) => {
311                transaction.rollback().await?;
312                return Err(err);
313            }
314        };
315
316        transaction.commit().await?;
317
318        super::invalidate_cache_keys(&state.database, &result.cache_invalidation_keys()).await;
319
320        result.invalidate_wings_permissions(&state.database).await;
321
322        Ok(result)
323    }
324
325    async fn create_with_transaction(
326        state: &crate::State,
327        mut options: Self::CreateOptions<'_>,
328        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
329    ) -> Result<Self, crate::database::DatabaseError> {
330        options.validate()?;
331
332        let user = match super::user::User::by_email(&state.database, &options.email).await? {
333            Some(user) => user,
334            None => {
335                let username = options
336                    .email
337                    .split('@')
338                    .next()
339                    .unwrap_or("unknown")
340                    .chars()
341                    .filter(|c| c.is_alphanumeric() || *c == '_')
342                    .take(10)
343                    .collect::<compact_str::CompactString>();
344                let username = compact_str::format_compact!(
345                    "{username}_{}",
346                    rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 4)
347                );
348
349                let app_settings = state.settings.get().await?;
350
351                let create_options = super::user::CreateUserOptions {
352                    role_uuid: None,
353                    external_id: None,
354                    username: username.clone(),
355                    email: options.email.clone(),
356                    name_first: None,
357                    name_last: None,
358                    password: None,
359                    admin: false,
360                    frozen: false,
361                    suspended: false,
362                    verify_email: false,
363                    send_email: true,
364                    language: app_settings.app.language.clone(),
365                };
366                drop(app_settings);
367                match super::user::User::create(state, create_options).await {
368                    Ok(user) => user,
369                    Err(err) => {
370                        tracing::error!(username = %username, email = %options.email, "failed to create subuser user: {:?}", err);
371                        return Err(err);
372                    }
373                }
374            }
375        };
376
377        if options.server.owner.uuid == user.uuid {
378            return Err(sqlx::Error::InvalidArgument(
379                "cannot create subuser for server owner".into(),
380            )
381            .into());
382        }
383
384        let server_uuid = options.server.uuid;
385        let username = user.username.clone();
386
387        let mut query_builder = InsertQueryBuilder::new("server_subusers");
388
389        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
390
391        query_builder
392            .set("server_uuid", options.server.uuid)
393            .set("user_uuid", user.uuid)
394            .set("permissions", &options.permissions)
395            .set("ignored_files", &options.ignored_files);
396
397        query_builder.execute(&mut **transaction).await?;
398
399        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
400            r#"
401            SELECT {}
402            FROM server_subusers
403            JOIN users ON users.uuid = server_subusers.user_uuid
404            LEFT JOIN roles ON roles.uuid = users.role_uuid
405            WHERE server_subusers.server_uuid = $1 AND users.username = $2
406            "#,
407            Self::columns_sql(None)
408        )))
409        .bind(server_uuid)
410        .bind(username.as_str())
411        .fetch_one(&mut **transaction)
412        .await?;
413
414        let mut result = Self::map(None, &row)?;
415
416        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
417
418        let settings = state.settings.get().await?;
419        state
420            .mail
421            .send_template(
422                state,
423                "added_to_server",
424                &user,
425                minijinja::context! {
426                    user => minijinja::value::Serde(&user),
427                    server => minijinja::value::Serde(&options.server),
428                    server_link => format!(
429                        "{}/server/{:08x}",
430                        settings.app.url,
431                        options.server.uuid_short,
432                    )
433                },
434            )
435            .await;
436
437        Ok(result)
438    }
439}
440
441#[derive(ToSchema, Serialize, Deserialize, Validate, Default)]
442pub struct UpdateServerSubuserOptions {
443    #[garde(inner(custom(crate::permissions::validate_server_permissions)))]
444    pub permissions: Option<Vec<compact_str::CompactString>>,
445    #[garde(inner(
446        length(max = 1024),
447        inner(length(chars, min = 1, max = 255)),
448        custom(crate::utils::validate_ignored_files)
449    ))]
450    pub ignored_files: Option<Vec<compact_str::CompactString>>,
451}
452
453#[async_trait::async_trait]
454impl UpdatableModel for ServerSubuser {
455    type UpdateOptions = UpdateServerSubuserOptions;
456
457    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
458        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<ServerSubuser>> =
459            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
460
461        &UPDATE_LISTENERS
462    }
463
464    async fn update(
465        &mut self,
466        state: &crate::State,
467        options: Self::UpdateOptions,
468    ) -> Result<(), crate::database::DatabaseError> {
469        let mut transaction = state.database.write().begin().await?;
470
471        if let Err(err) = self
472            .update_with_transaction(state, options, &mut transaction)
473            .await
474        {
475            transaction.rollback().await?;
476            return Err(err);
477        }
478
479        transaction.commit().await?;
480
481        super::invalidate_cache_keys(&state.database, &self.cache_invalidation_keys()).await;
482
483        self.invalidate_wings_permissions(&state.database).await;
484
485        Ok(())
486    }
487
488    async fn update_with_transaction(
489        &mut self,
490        state: &crate::State,
491        mut options: Self::UpdateOptions,
492        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
493    ) -> Result<(), crate::database::DatabaseError> {
494        options.validate()?;
495
496        let mut query_builder = UpdateQueryBuilder::new("server_subusers");
497
498        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
499            .await?;
500
501        query_builder
502            .set("permissions", options.permissions.as_ref())
503            .set("ignored_files", options.ignored_files.as_ref())
504            .where_eq("server_uuid", self.server.uuid)
505            .where_eq("user_uuid", self.user.uuid);
506
507        query_builder.execute(&mut **transaction).await?;
508
509        if let Some(permissions) = options.permissions {
510            self.permissions = permissions;
511        }
512        if let Some(ignored_files) = options.ignored_files {
513            self.ignored_files = ignored_files;
514        }
515
516        self.run_after_update_handlers(state, transaction).await?;
517
518        Ok(())
519    }
520}
521
522#[async_trait::async_trait]
523impl DeletableModel for ServerSubuser {
524    type DeleteOptions = ();
525
526    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
527        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<ServerSubuser>> =
528            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
529
530        &DELETE_LISTENERS
531    }
532
533    async fn delete(
534        &self,
535        state: &crate::State,
536        options: Self::DeleteOptions,
537    ) -> Result<(), anyhow::Error> {
538        let mut transaction = state.database.write().begin().await?;
539
540        if let Err(err) = self
541            .delete_with_transaction(state, options, &mut transaction)
542            .await
543        {
544            transaction.rollback().await?;
545            return Err(err);
546        }
547
548        transaction.commit().await?;
549
550        super::invalidate_cache_keys(&state.database, &self.cache_invalidation_keys()).await;
551
552        self.invalidate_wings_permissions(&state.database).await;
553
554        Ok(())
555    }
556
557    async fn delete_with_transaction(
558        &self,
559        state: &crate::State,
560        options: Self::DeleteOptions,
561        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
562    ) -> Result<(), anyhow::Error> {
563        self.run_delete_handlers(&options, state, transaction)
564            .await?;
565
566        sqlx::query(
567            r#"
568            DELETE FROM server_subusers
569            WHERE server_subusers.server_uuid = $1 AND server_subusers.user_uuid = $2
570            "#,
571        )
572        .bind(self.server.uuid)
573        .bind(self.user.uuid)
574        .execute(&mut **transaction)
575        .await?;
576
577        self.run_after_delete_handlers(&options, state, transaction)
578            .await?;
579
580        state
581            .mail
582            .send_template(
583                state,
584                "removed_from_server",
585                &self.user,
586                minijinja::context! {
587                    user => minijinja::value::Serde(&self.user),
588                },
589            )
590            .await;
591
592        Ok(())
593    }
594}
595
596#[schema_extension_derive::extendible]
597#[init_args(ServerSubuser, crate::State)]
598#[hook_args(crate::State)]
599#[derive(ToSchema, Serialize)]
600#[schema(title = "ServerSubuser")]
601pub struct ApiServerSubuser {
602    pub user: super::user::ApiUser,
603
604    pub permissions: Vec<compact_str::CompactString>,
605    pub ignored_files: Vec<compact_str::CompactString>,
606
607    pub created: chrono::DateTime<chrono::Utc>,
608}