feat(v1.1.8): per-app roles + roles in user record (migration 0031)
migration 0031: app_user_roles table — composite PK (app_id, user_id,
role) so add is idempotent (ON CONFLICT DO NOTHING). v1.1.8 stores
strings only; permission matrices / hierarchies / role registry are
explicitly v1.2 work per the brief.
UsersServiceImpl wires roles: Arc<dyn AppUserRoleRepo>:
* fetch_roles() now actually queries the repo (replacing the empty
Vec stub from commit 4). Every AppUser returned from get /
find_by_email / list / update / verify / login now carries its
role list.
* users::add_role gated on AppUsersAdmin; first checks the user
exists in this app so a FK violation can't leak "no such user".
* users::remove_role gated on AppUsersAdmin; idempotent.
* users::has_role gated on AppUsersRead.
* accept_invite now applies pre-staged roles atomically with the
user creation; malformed role strings are skipped with a warn
rather than aborting the whole accept (the invitation was an
admin's promise — we honor as much of it as we can).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
crates/manager-core/migrations/0031_app_user_roles.sql
Normal file
21
crates/manager-core/migrations/0031_app_user_roles.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- v1.1.8 User Management — per-app string-tagged roles.
|
||||||
|
--
|
||||||
|
-- v1.1.8 ships ROLE STORAGE ONLY. There is no role registry, no
|
||||||
|
-- hierarchy, no permission matrix — what "admin" / "editor" /
|
||||||
|
-- "viewer" mean is up to the script app. The `users::*` SDK
|
||||||
|
-- surfaces a Vec<String> on every AppUser record; the surrounding
|
||||||
|
-- script reads it and gates behavior accordingly.
|
||||||
|
--
|
||||||
|
-- Per-role permission matrices are a v1.2 design item (see brief);
|
||||||
|
-- pre-baking them would either cement a wrong model or force a
|
||||||
|
-- breaking change at v1.2.
|
||||||
|
|
||||||
|
CREATE TABLE app_user_roles (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, user_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_app_user_roles_user ON app_user_roles (app_id, user_id);
|
||||||
132
crates/manager-core/src/app_user_role_repo.rs
Normal file
132
crates/manager-core/src/app_user_role_repo.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! CRUD over `app_user_roles` (v1.1.8 commit 8).
|
||||||
|
//!
|
||||||
|
//! String-tagged roles only. No registry, no hierarchy, no
|
||||||
|
//! permission matrix — the script app decides what the strings mean.
|
||||||
|
//! Per-app, per-user; the PK is `(app_id, user_id, role)` so the
|
||||||
|
//! `add` path is idempotent (`ON CONFLICT DO NOTHING`).
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppId, AppUserId};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppUserRoleRepoError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppUserRoleRepo: Send + Sync {
|
||||||
|
/// Idempotent add — duplicate role for the same user is a no-op.
|
||||||
|
async fn add(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<(), AppUserRoleRepoError>;
|
||||||
|
|
||||||
|
/// Returns whether the role was actually removed (false if it
|
||||||
|
/// wasn't there).
|
||||||
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<bool, AppUserRoleRepoError>;
|
||||||
|
|
||||||
|
async fn has(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<bool, AppUserRoleRepoError>;
|
||||||
|
|
||||||
|
/// All roles for a single user. The AppUser DTO carries this list.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
) -> Result<Vec<String>, AppUserRoleRepoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppUserRoleRepo {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppUserRoleRepo {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppUserRoleRepo for PostgresAppUserRoleRepo {
|
||||||
|
async fn add(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<(), AppUserRoleRepoError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO app_user_roles (app_id, user_id, role) VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (app_id, user_id, role) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<bool, AppUserRoleRepoError> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"DELETE FROM app_user_roles WHERE app_id = $1 AND user_id = $2 AND role = $3",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn has(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<bool, AppUserRoleRepoError> {
|
||||||
|
let row: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT 1::BIGINT FROM app_user_roles \
|
||||||
|
WHERE app_id = $1 AND user_id = $2 AND role = $3",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AppUserId,
|
||||||
|
) -> Result<Vec<String>, AppUserRoleRepoError> {
|
||||||
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT role FROM app_user_roles WHERE app_id = $1 AND user_id = $2 ORDER BY role",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|(s,)| s).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub mod app_secrets_repo;
|
|||||||
pub mod app_user_invitation_repo;
|
pub mod app_user_invitation_repo;
|
||||||
pub mod app_user_password_reset_repo;
|
pub mod app_user_password_reset_repo;
|
||||||
pub mod app_user_repo;
|
pub mod app_user_repo;
|
||||||
|
pub mod app_user_role_repo;
|
||||||
pub mod app_user_session_repo;
|
pub mod app_user_session_repo;
|
||||||
pub mod app_user_verification_repo;
|
pub mod app_user_verification_repo;
|
||||||
pub mod users_service;
|
pub mod users_service;
|
||||||
@@ -97,6 +98,7 @@ pub use app_user_invitation_repo::{
|
|||||||
pub use app_user_password_reset_repo::{
|
pub use app_user_password_reset_repo::{
|
||||||
AppUserPasswordResetRepo, AppUserPasswordResetRepoError, PostgresAppUserPasswordResetRepo,
|
AppUserPasswordResetRepo, AppUserPasswordResetRepoError, PostgresAppUserPasswordResetRepo,
|
||||||
};
|
};
|
||||||
|
pub use app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError, PostgresAppUserRoleRepo};
|
||||||
pub use app_user_verification_repo::{
|
pub use app_user_verification_repo::{
|
||||||
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ use crate::app_user_password_reset_repo::{
|
|||||||
use crate::app_user_repo::{
|
use crate::app_user_repo::{
|
||||||
AppUserRepository, AppUserRepositoryError, AppUserRow, ListOpts as RepoListOpts,
|
AppUserRepository, AppUserRepositoryError, AppUserRow, ListOpts as RepoListOpts,
|
||||||
};
|
};
|
||||||
|
use crate::app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError};
|
||||||
use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError};
|
use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError};
|
||||||
use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerificationRepoError};
|
use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerificationRepoError};
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
@@ -129,6 +130,7 @@ pub struct UsersServiceImpl {
|
|||||||
verifications: Arc<dyn AppUserVerificationRepo>,
|
verifications: Arc<dyn AppUserVerificationRepo>,
|
||||||
password_resets: Arc<dyn AppUserPasswordResetRepo>,
|
password_resets: Arc<dyn AppUserPasswordResetRepo>,
|
||||||
invitations: Arc<dyn AppUserInvitationRepo>,
|
invitations: Arc<dyn AppUserInvitationRepo>,
|
||||||
|
roles: Arc<dyn AppUserRoleRepo>,
|
||||||
email: Arc<dyn EmailService>,
|
email: Arc<dyn EmailService>,
|
||||||
authz: Arc<dyn AuthzRepo>,
|
authz: Arc<dyn AuthzRepo>,
|
||||||
events: Arc<dyn ServiceEventEmitter>,
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
@@ -144,6 +146,7 @@ impl UsersServiceImpl {
|
|||||||
verifications: Arc<dyn AppUserVerificationRepo>,
|
verifications: Arc<dyn AppUserVerificationRepo>,
|
||||||
password_resets: Arc<dyn AppUserPasswordResetRepo>,
|
password_resets: Arc<dyn AppUserPasswordResetRepo>,
|
||||||
invitations: Arc<dyn AppUserInvitationRepo>,
|
invitations: Arc<dyn AppUserInvitationRepo>,
|
||||||
|
roles: Arc<dyn AppUserRoleRepo>,
|
||||||
email: Arc<dyn EmailService>,
|
email: Arc<dyn EmailService>,
|
||||||
authz: Arc<dyn AuthzRepo>,
|
authz: Arc<dyn AuthzRepo>,
|
||||||
events: Arc<dyn ServiceEventEmitter>,
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
@@ -155,6 +158,7 @@ impl UsersServiceImpl {
|
|||||||
verifications,
|
verifications,
|
||||||
password_resets,
|
password_resets,
|
||||||
invitations,
|
invitations,
|
||||||
|
roles,
|
||||||
email,
|
email,
|
||||||
authz,
|
authz,
|
||||||
events,
|
events,
|
||||||
@@ -192,10 +196,8 @@ impl UsersServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_roles(&self, _app_id: AppId, _user_id: AppUserId) -> Result<Vec<String>, UsersError> {
|
async fn fetch_roles(&self, app_id: AppId, user_id: AppUserId) -> Result<Vec<String>, UsersError> {
|
||||||
// Commit 8 (per-app roles, migration 0031) replaces this with
|
Ok(self.roles.list_for_user(app_id, user_id).await?)
|
||||||
// a SELECT from app_user_roles.
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn emit(&self, cx: &SdkCallCx, op: &'static str, user_id: AppUserId) {
|
async fn emit(&self, cx: &SdkCallCx, op: &'static str, user_id: AppUserId) {
|
||||||
@@ -299,6 +301,25 @@ impl From<AppUserInvitationRepoError> for UsersError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AppUserRoleRepoError> for UsersError {
|
||||||
|
fn from(e: AppUserRoleRepoError) -> Self {
|
||||||
|
match e {
|
||||||
|
AppUserRoleRepoError::Db(err) => UsersError::Backend(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_role(role: &str) -> Result<String, UsersError> {
|
||||||
|
let trimmed = role.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(UsersError::Invalid("role must not be empty".into()));
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() > 128 {
|
||||||
|
return Err(UsersError::Invalid("role exceeds 128 characters".into()));
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn map_email_error(e: EmailError) -> UsersError {
|
fn map_email_error(e: EmailError) -> UsersError {
|
||||||
match e {
|
match e {
|
||||||
EmailError::NotConfigured => UsersError::EmailNotConfigured,
|
EmailError::NotConfigured => UsersError::EmailNotConfigured,
|
||||||
@@ -812,47 +833,79 @@ impl UsersService for UsersServiceImpl {
|
|||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
// Commit 8 wires the role-application path; in commit 7 the
|
// Apply pre-staged roles (commit 8). Each role goes through
|
||||||
// staged roles are stored on the invitation row but not
|
// the same trim + length validation the script's add_role
|
||||||
// applied (the role table doesn't exist yet). Logged so an
|
// call would; invalid ones are skipped with a warn rather
|
||||||
// operator can audit the gap.
|
// than aborting the whole accept (the invitation was a
|
||||||
if !consumed.roles.is_empty() {
|
// promise the admin made; we honor as much of it as we can).
|
||||||
tracing::info!(
|
|
||||||
user_id = %row.id,
|
|
||||||
app_id = %cx.app_id,
|
|
||||||
roles = ?consumed.roles,
|
|
||||||
"accept_invite: pre-staged roles will be applied once migration 0031 lands"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let user_id = row.id;
|
let user_id = row.id;
|
||||||
let user = Self::to_app_user(row, Vec::new());
|
let mut applied: Vec<String> = Vec::with_capacity(consumed.roles.len());
|
||||||
|
for raw_role in &consumed.roles {
|
||||||
|
match validate_role(raw_role) {
|
||||||
|
Ok(role) => {
|
||||||
|
self.roles.add(cx.app_id, user_id, &role).await?;
|
||||||
|
applied.push(role);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
invalid_role = %raw_role,
|
||||||
|
user_id = %user_id,
|
||||||
|
app_id = %cx.app_id,
|
||||||
|
error = %e,
|
||||||
|
"accept_invite: skipping malformed pre-staged role"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let user = Self::to_app_user(row, applied);
|
||||||
let session = self.mint_session(cx.app_id, user_id, &user).await?;
|
let session = self.mint_session(cx.app_id, user_id, &user).await?;
|
||||||
self.emit(cx, "create", user_id).await;
|
self.emit(cx, "create", user_id).await;
|
||||||
Ok(Some(GeneratedAccept { user, session }))
|
Ok(Some(GeneratedAccept { user, session }))
|
||||||
}
|
}
|
||||||
async fn add_role(
|
async fn add_role(
|
||||||
&self,
|
&self,
|
||||||
_cx: &SdkCallCx,
|
cx: &SdkCallCx,
|
||||||
_user_id: AppUserId,
|
user_id: AppUserId,
|
||||||
_role: &str,
|
role: &str,
|
||||||
) -> Result<(), UsersError> {
|
) -> Result<(), UsersError> {
|
||||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
self.require(cx.principal.as_ref(), Capability::AppUsersAdmin(cx.app_id))
|
||||||
|
.await?;
|
||||||
|
// Ensure the user actually exists in this app — otherwise an
|
||||||
|
// FK-failing insert leaks "no such user" via the error shape.
|
||||||
|
let exists = self.users.get(cx.app_id, user_id).await?.is_some();
|
||||||
|
if !exists {
|
||||||
|
return Err(UsersError::NotFound);
|
||||||
|
}
|
||||||
|
let role = validate_role(role)?;
|
||||||
|
self.roles.add(cx.app_id, user_id, &role).await?;
|
||||||
|
self.emit(cx, "role_added", user_id).await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn remove_role(
|
async fn remove_role(
|
||||||
&self,
|
&self,
|
||||||
_cx: &SdkCallCx,
|
cx: &SdkCallCx,
|
||||||
_user_id: AppUserId,
|
user_id: AppUserId,
|
||||||
_role: &str,
|
role: &str,
|
||||||
) -> Result<bool, UsersError> {
|
) -> Result<bool, UsersError> {
|
||||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
self.require(cx.principal.as_ref(), Capability::AppUsersAdmin(cx.app_id))
|
||||||
|
.await?;
|
||||||
|
let role = validate_role(role)?;
|
||||||
|
let removed = self.roles.remove(cx.app_id, user_id, &role).await?;
|
||||||
|
if removed {
|
||||||
|
self.emit(cx, "role_removed", user_id).await;
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
async fn has_role(
|
async fn has_role(
|
||||||
&self,
|
&self,
|
||||||
_cx: &SdkCallCx,
|
cx: &SdkCallCx,
|
||||||
_user_id: AppUserId,
|
user_id: AppUserId,
|
||||||
_role: &str,
|
role: &str,
|
||||||
) -> Result<bool, UsersError> {
|
) -> Result<bool, UsersError> {
|
||||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
self.require(cx.principal.as_ref(), Capability::AppUsersRead(cx.app_id))
|
||||||
|
.await?;
|
||||||
|
let role = validate_role(role)?;
|
||||||
|
Ok(self.roles.has(cx.app_id, user_id, &role).await?)
|
||||||
}
|
}
|
||||||
async fn admin_reset_password_token(
|
async fn admin_reset_password_token(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use picloud_manager_core::{
|
|||||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
|
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||||
PostgresDeadLetterService,
|
PostgresDeadLetterService,
|
||||||
PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo,
|
PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo,
|
||||||
@@ -258,12 +258,14 @@ pub async fn build_app(
|
|||||||
Arc::new(PostgresAppUserPasswordResetRepo::new(pool.clone()));
|
Arc::new(PostgresAppUserPasswordResetRepo::new(pool.clone()));
|
||||||
let app_user_invitations_repo =
|
let app_user_invitations_repo =
|
||||||
Arc::new(PostgresAppUserInvitationRepo::new(pool.clone()));
|
Arc::new(PostgresAppUserInvitationRepo::new(pool.clone()));
|
||||||
|
let app_user_roles_repo = Arc::new(PostgresAppUserRoleRepo::new(pool.clone()));
|
||||||
let users: Arc<dyn UsersService> = Arc::new(UsersServiceImpl::new(
|
let users: Arc<dyn UsersService> = Arc::new(UsersServiceImpl::new(
|
||||||
app_users_repo.clone(),
|
app_users_repo.clone(),
|
||||||
app_user_sessions_repo.clone(),
|
app_user_sessions_repo.clone(),
|
||||||
app_user_verifications_repo.clone(),
|
app_user_verifications_repo.clone(),
|
||||||
app_user_password_resets_repo.clone(),
|
app_user_password_resets_repo.clone(),
|
||||||
app_user_invitations_repo.clone(),
|
app_user_invitations_repo.clone(),
|
||||||
|
app_user_roles_repo.clone(),
|
||||||
email.clone(),
|
email.clone(),
|
||||||
authz.clone(),
|
authz.clone(),
|
||||||
events.clone(),
|
events.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user