magic token login
This commit is contained in:
+129
-71
@@ -1,9 +1,12 @@
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use diesel::{dsl::exists, prelude::*, select};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use papercrate::{
|
||||
@@ -12,18 +15,31 @@ use papercrate::{
|
||||
documents::search::ensure_quickwit_index,
|
||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||
models::{
|
||||
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User,
|
||||
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
|
||||
Tenant, TenantStatus, User,
|
||||
},
|
||||
s3,
|
||||
schema::{
|
||||
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
|
||||
document_asset_objects, document_assets, documents, magic_tokens, tenants,
|
||||
user_memberships, users,
|
||||
},
|
||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||
tenants::TenantService,
|
||||
utils::tracing::init_tracing,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "papercrate-admin",
|
||||
version,
|
||||
about = "Papercrate administration utility"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
CreateUser {
|
||||
username: String,
|
||||
@@ -34,7 +50,9 @@ enum Command {
|
||||
},
|
||||
CreateTenant {
|
||||
name: String,
|
||||
#[arg(long = "storage-root")]
|
||||
storage_root: Option<String>,
|
||||
#[arg(long = "quickwit-index")]
|
||||
quickwit_index: Option<String>,
|
||||
},
|
||||
DeleteTenant {
|
||||
@@ -52,71 +70,43 @@ enum Command {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
ListTenants,
|
||||
DeleteAssets(Uuid),
|
||||
QuickwitCreate(Uuid),
|
||||
QuickwitDelete(Uuid),
|
||||
DeleteAssets {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
QuickwitCreate {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
QuickwitDelete {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
MagicToken {
|
||||
username: String,
|
||||
#[arg(long = "ttl-minutes", default_value_t = 10)]
|
||||
ttl_minutes: i64,
|
||||
#[arg(
|
||||
long = "max-uses",
|
||||
value_name = "MAX_USES",
|
||||
help = "Maximum number of uses before the token is rejected (default: unlimited)"
|
||||
)]
|
||||
max_uses: Option<i32>,
|
||||
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
|
||||
kind: MagicTokenKindArg,
|
||||
},
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn usage() -> &'static str {
|
||||
"Usage: admin\n\
|
||||
create-user <username>\n\
|
||||
list-users\n\
|
||||
delete-user <username>\n\
|
||||
create-tenant <name> [storage_root] [quickwit_index]\n\
|
||||
delete-tenant <tenant-id>\n\
|
||||
add-user-to-tenant <username> <tenant-id>\n\
|
||||
remove-user-from-tenant <username> <tenant-id>\n\
|
||||
reanalyze-documents <tenant-id>\n\
|
||||
list-tenants\n\
|
||||
delete-assets <tenant-id>\n\
|
||||
quickwit-create-index <tenant-id>\n\
|
||||
quickwit-delete-index <tenant-id>"
|
||||
}
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum MagicTokenKindArg {
|
||||
#[value(name = "email_login")]
|
||||
EmailLogin,
|
||||
#[value(name = "demo_login")]
|
||||
DemoLogin,
|
||||
}
|
||||
|
||||
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> {
|
||||
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?;
|
||||
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw))
|
||||
}
|
||||
|
||||
fn parse() -> Result<Self> {
|
||||
let mut args = env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
Some("create-user") => Ok(Self::CreateUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
}),
|
||||
Some("list-users") => Ok(Self::ListUsers),
|
||||
Some("delete-user") => Ok(Self::DeleteUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
}),
|
||||
Some("create-tenant") => Ok(Self::CreateTenant {
|
||||
name: args.next().ok_or_else(|| anyhow!("tenant name required"))?,
|
||||
storage_root: args.next(),
|
||||
quickwit_index: args.next(),
|
||||
}),
|
||||
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("list-tenants") => Ok(Self::ListTenants),
|
||||
Some("delete-assets") => Ok(Self::DeleteAssets(Self::parse_tenant_id(args.next())?)),
|
||||
Some("quickwit-create-index") => {
|
||||
Ok(Self::QuickwitCreate(Self::parse_tenant_id(args.next())?))
|
||||
}
|
||||
Some("quickwit-delete-index") => {
|
||||
Ok(Self::QuickwitDelete(Self::parse_tenant_id(args.next())?))
|
||||
}
|
||||
_ => Err(anyhow!(Self::usage())),
|
||||
impl From<MagicTokenKindArg> for MagicTokenKind {
|
||||
fn from(value: MagicTokenKindArg) -> Self {
|
||||
match value {
|
||||
MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
|
||||
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +114,11 @@ impl Command {
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_tracing("info");
|
||||
let command = Command::parse()?;
|
||||
let cli = Cli::parse();
|
||||
let config = AppConfig::load_and_log("admin")?;
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
match command {
|
||||
match cli.command {
|
||||
Command::CreateUser { username } => create_user(&pool, &username)?,
|
||||
Command::ListUsers => list_users(&pool)?,
|
||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||
@@ -148,15 +138,23 @@ async fn main() -> Result<()> {
|
||||
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
||||
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
||||
Command::ListTenants => list_tenants(&pool)?,
|
||||
Command::DeleteAssets(tenant_id) => {
|
||||
Command::DeleteAssets { tenant_id } => {
|
||||
delete_assets_for_tenant(&config, &pool, tenant_id).await?
|
||||
}
|
||||
Command::QuickwitCreate(tenant_id) => {
|
||||
Command::QuickwitCreate { tenant_id } => {
|
||||
quickwit_index(&config, &pool, tenant_id, Method::POST).await?
|
||||
}
|
||||
Command::QuickwitDelete(tenant_id) => {
|
||||
Command::QuickwitDelete { tenant_id } => {
|
||||
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
|
||||
}
|
||||
Command::MagicToken {
|
||||
username,
|
||||
ttl_minutes,
|
||||
max_uses,
|
||||
kind,
|
||||
} => {
|
||||
create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -268,6 +266,66 @@ fn create_tenant(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_magic_token(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
ttl_minutes: i64,
|
||||
max_uses: Option<i32>,
|
||||
kind: MagicTokenKind,
|
||||
) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.with_context(|| format!("user '{}' not found", username))?;
|
||||
|
||||
let raw_token = generate_random_token();
|
||||
let token_hash = hash_token(&raw_token);
|
||||
let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes);
|
||||
|
||||
let new_token = MagicToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
kind,
|
||||
token_hash,
|
||||
metadata: serde_json::json!({}),
|
||||
expires_at: expires_at.naive_utc(),
|
||||
max_uses,
|
||||
used_count: 0,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
created_by: None,
|
||||
last_used_at: None,
|
||||
};
|
||||
|
||||
diesel::insert_into(magic_tokens::table)
|
||||
.values(&new_token)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!(
|
||||
"Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})",
|
||||
username,
|
||||
kind.as_str(),
|
||||
expires_at,
|
||||
max_uses.map_or("∞".to_string(), |v| v.to_string())
|
||||
);
|
||||
println!("Token: {}", raw_token);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_random_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
|
||||
+77
-1
@@ -10,7 +10,9 @@ use std::io::Write;
|
||||
use std::str;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::schema::sql_types::TenantStatus as TenantStatusSql;
|
||||
use crate::schema::sql_types::{
|
||||
MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql,
|
||||
};
|
||||
use crate::schema::*;
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -43,6 +45,64 @@ pub enum TenantStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = MagicTokenKindSql)]
|
||||
pub enum MagicTokenKind {
|
||||
EmailLogin,
|
||||
DemoLogin,
|
||||
}
|
||||
|
||||
impl MagicTokenKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MagicTokenKind::EmailLogin => "email_login",
|
||||
MagicTokenKind::DemoLogin => "demo_login",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn variants() -> &'static [&'static str] {
|
||||
&["email_login", "demo_login"]
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MagicTokenKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||
out.write_all(self.as_str().as_bytes())?;
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||
match std::str::from_utf8(bytes.as_bytes())? {
|
||||
"email_login" => Ok(MagicTokenKind::EmailLogin),
|
||||
"demo_login" => Ok(MagicTokenKind::DemoLogin),
|
||||
other => Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("invalid magic_token_kind '{other}'"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl str::FromStr for MagicTokenKind {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"email_login" => Ok(MagicTokenKind::EmailLogin),
|
||||
"demo_login" => Ok(MagicTokenKind::DemoLogin),
|
||||
_ => Err("unsupported magic token kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TenantStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -265,6 +325,22 @@ pub struct NewDocument {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Insertable)]
|
||||
#[diesel(table_name = magic_tokens)]
|
||||
pub struct MagicToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub kind: MagicTokenKind,
|
||||
pub token_hash: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub expires_at: NaiveDateTime,
|
||||
pub max_uses: Option<i32>,
|
||||
pub used_count: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = document_versions)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
|
||||
+109
-8
@@ -25,10 +25,13 @@ use crate::{
|
||||
AuthenticatedUser,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||
models::{
|
||||
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus,
|
||||
User, UserMembership,
|
||||
},
|
||||
schema::{
|
||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||
user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -40,8 +43,14 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub magic_token: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub preferred_tenant_id: Option<Uuid>,
|
||||
@@ -152,10 +161,41 @@ pub struct AuthApiDoc;
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
|
||||
Err(AppError::bad_request(
|
||||
"password authentication is no longer supported",
|
||||
))
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let magic_token = payload
|
||||
.magic_token
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if magic_token.is_none() {
|
||||
if payload.password.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"password authentication is no longer supported",
|
||||
));
|
||||
}
|
||||
|
||||
return Err(AppError::bad_request(
|
||||
"magic_token is required for passwordless login",
|
||||
));
|
||||
}
|
||||
|
||||
let token_value = magic_token.unwrap();
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let username_hint = payload.username.trim();
|
||||
let preferred_tenant_id = payload.preferred_tenant_id;
|
||||
|
||||
magic_token_login(
|
||||
&state,
|
||||
&mut conn,
|
||||
token_value,
|
||||
(!username_hint.is_empty()).then_some(username_hint),
|
||||
preferred_tenant_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -644,6 +684,63 @@ fn complete_login(
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn magic_token_login(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
token_value: &str,
|
||||
username_hint: Option<&str>,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
if token_value.is_empty() {
|
||||
return Err(AppError::bad_request("magic_token must not be empty"));
|
||||
}
|
||||
|
||||
let token_hash = hash_magic_token(token_value);
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
conn.transaction::<Response, AppError, _>(|conn| {
|
||||
let magic = magic_dsl::magic_tokens
|
||||
.filter(magic_dsl::token_hash.eq(&token_hash))
|
||||
.filter(magic_dsl::expires_at.gt(now_naive))
|
||||
.first::<MagicToken>(conn)
|
||||
.map_err(|err| match err {
|
||||
diesel::result::Error::NotFound => AppError::unauthorized(),
|
||||
_ => AppError::from(err),
|
||||
})?;
|
||||
|
||||
if let Some(limit) = magic.max_uses {
|
||||
if magic.used_count >= limit {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
match magic.kind {
|
||||
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(magic.user_id)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if let Some(expected) = username_hint {
|
||||
if expected != user.username {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
|
||||
.set((
|
||||
magic_dsl::used_count.eq(magic.used_count + 1),
|
||||
magic_dsl::last_used_at.eq(Some(now_naive)),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
complete_login(state, conn, &user, preferred_tenant_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn issue_session(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
@@ -704,6 +801,10 @@ fn hash_refresh_token(token: &str) -> String {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn hash_magic_token(token: &str) -> String {
|
||||
hash_refresh_token(token)
|
||||
}
|
||||
|
||||
fn generate_refresh_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
pub mod sql_types {
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "magic_token_kind"))]
|
||||
pub struct MagicTokenKind;
|
||||
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "tenant_status"))]
|
||||
pub struct TenantStatus;
|
||||
@@ -127,6 +131,25 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::MagicTokenKind;
|
||||
|
||||
magic_tokens (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
kind -> MagicTokenKind,
|
||||
token_hash -> Varchar,
|
||||
metadata -> Jsonb,
|
||||
expires_at -> Timestamptz,
|
||||
max_uses -> Nullable<Int4>,
|
||||
used_count -> Int4,
|
||||
created_at -> Timestamptz,
|
||||
created_by -> Nullable<Uuid>,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
refresh_tokens (id) {
|
||||
id -> Uuid,
|
||||
@@ -275,6 +298,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
documents,
|
||||
folders,
|
||||
jobs,
|
||||
magic_tokens,
|
||||
refresh_tokens,
|
||||
tags,
|
||||
tenants,
|
||||
|
||||
Reference in New Issue
Block a user