feat(notify): add notif to internal users when granted
- add coalesced protection to avoid mail bombing if an invited goes many grant in a short period
- add resentd method in share menu item (work for both internal and external users)
- user can disable email notification via his properties
- add env variable from admin to disable notifications
This commit is contained in:
@@ -97,10 +97,10 @@ impl UserRepository for UserPgRepository {
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, is_external,
|
||||
given_name, family_name, email_verified_at,
|
||||
preferred_locale
|
||||
preferred_locale, notify_on_share
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17, $18
|
||||
$12, $13, $14, $15, $16, $17, $18, $19
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
@@ -123,6 +123,7 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.family_name())
|
||||
.bind(user_clone.email_verified_at())
|
||||
.bind(user_clone.preferred_locale())
|
||||
.bind(user_clone.notify_on_share())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -147,7 +148,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -184,6 +185,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -196,7 +198,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
@@ -233,6 +235,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -245,7 +248,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
@@ -282,9 +285,71 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Batch loads users by id in one query (avoids N+1 for group-
|
||||
/// recipient expansion). Missing ids are silently skipped — the
|
||||
/// caller treats absent rows as "no such recipient", same as
|
||||
/// `get_user_by_id` returning `NotFound` for a single lookup.
|
||||
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
|
||||
let role = match role_str.as_deref() {
|
||||
Some("admin") => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
User::from_data_full(
|
||||
row.get("id"),
|
||||
row.get("username"),
|
||||
row.get("email"),
|
||||
row.get("password_hash"),
|
||||
role,
|
||||
row.get("storage_quota_bytes"),
|
||||
row.get("storage_used_bytes"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
row.get("last_login_at"),
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Updates an existing user using a transaction
|
||||
async fn update_user(&self, user: User) -> UserRepositoryResult<User> {
|
||||
// Create a copy of the user for the closure
|
||||
@@ -310,7 +375,8 @@ impl UserRepository for UserPgRepository {
|
||||
given_name = $12,
|
||||
family_name = $13,
|
||||
email_verified_at = $14,
|
||||
preferred_locale = $15
|
||||
preferred_locale = $15,
|
||||
notify_on_share = $16
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -329,6 +395,7 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.family_name())
|
||||
.bind(user_clone.email_verified_at())
|
||||
.bind(user_clone.preferred_locale())
|
||||
.bind(user_clone.notify_on_share())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -401,7 +468,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE ($3 OR is_external = FALSE)
|
||||
ORDER BY created_at DESC
|
||||
@@ -445,6 +512,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -466,7 +534,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE (username ILIKE $1 OR email ILIKE $1)
|
||||
AND ($3 OR is_external = FALSE)
|
||||
@@ -510,6 +578,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -597,7 +666,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE role::text = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -638,6 +707,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -674,7 +744,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||
"#,
|
||||
@@ -711,6 +781,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -792,6 +863,12 @@ impl UserStoragePort for UserPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::get_users_by_ids(self, ids)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError> {
|
||||
UserRepository::get_user_by_username(self, username)
|
||||
.await
|
||||
|
||||
@@ -347,6 +347,32 @@ impl PgAclEngine {
|
||||
Ok(Some((res, granter)))
|
||||
}
|
||||
|
||||
/// Variant of `find_grant_by_id` that also returns the subject —
|
||||
/// needed by `POST /api/grants/{id}/notify` to resolve who to email.
|
||||
/// Returns `(subject, resource, granted_by)` or `None`.
|
||||
pub async fn find_grant_full_by_id(
|
||||
&self,
|
||||
grant_id: Uuid,
|
||||
) -> Result<Option<(Subject, Resource, Uuid)>, DomainError> {
|
||||
let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT subject_type, subject_id, resource_type, resource_id, granted_by \
|
||||
FROM storage.access_grants WHERE id = $1",
|
||||
)
|
||||
.bind(grant_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_full_by_id: {e}")))?;
|
||||
|
||||
let Some((st, sid, rt, rid, granter)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let subject = Subject::from_parts(&st, sid)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
|
||||
let resource = Resource::from_parts(&rt, rid)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
|
||||
Ok(Some((subject, resource, granter)))
|
||||
}
|
||||
|
||||
/// Row type for all full-grant SELECT queries:
|
||||
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -865,6 +891,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// 10 sort_str Option<String>
|
||||
// 11 sort_int Option<i64>
|
||||
// 12 has_password bool — token: shares.password_hash IS NOT NULL
|
||||
// 13 is_external bool — user: auth.users.is_external (PR N2);
|
||||
// FALSE for token/group subjects.
|
||||
type Row = (
|
||||
String,
|
||||
Uuid,
|
||||
@@ -879,6 +907,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
bool,
|
||||
bool,
|
||||
);
|
||||
|
||||
let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone());
|
||||
@@ -962,7 +991,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
rp.sort_str, rp.sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
@@ -1025,6 +1055,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.subject_id,
|
||||
MAX(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
|
||||
MAX(CASE
|
||||
WHEN ag.subject_type = 'group' THEN 0
|
||||
WHEN ag.subject_type = 'user' THEN 1
|
||||
@@ -1066,7 +1097,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.permission,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
@@ -1110,6 +1142,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.subject_id,
|
||||
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
|
||||
CASE
|
||||
WHEN BOOL_OR(ag.permission = 'delete')
|
||||
AND BOOL_OR(ag.permission = 'share') THEN 0
|
||||
@@ -1150,7 +1183,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.permission,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
@@ -1199,7 +1233,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
NULL::text AS sort_str, NULL::bigint AS sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
@@ -1283,6 +1318,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
is_external,
|
||||
) = r;
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
@@ -1303,6 +1339,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
granted_at,
|
||||
expires_at,
|
||||
has_password,
|
||||
is_external,
|
||||
},
|
||||
)
|
||||
});
|
||||
@@ -1399,6 +1436,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
is_external,
|
||||
) = r;
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
@@ -1425,6 +1463,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
granted_at,
|
||||
expires_at,
|
||||
has_password,
|
||||
is_external,
|
||||
});
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
|
||||
Reference in New Issue
Block a user