chore(load): start implementation of load tests

initial test from Ed's nuc:

    metric                                          pctl  baseline    current     delta    status
    -----------------------------------------------------------------------------------------------
    folder_cascade.list_depth1                      p50   0.3ms       0.3ms       -6.3%    ok
    folder_cascade.list_depth1                      p95   2.3ms       0.5ms       -75.7%   ok
    folder_cascade.list_depth1                      p99   4.7ms       2.5ms       -48.1%   ok
    folder_cascade.list_depth4                      p50   0.4ms       0.3ms       -10.0%   ok
    folder_cascade.list_depth4                      p95   0.9ms       0.6ms       -31.2%   ok
    folder_cascade.list_depth4                      p99   2.4ms       1.0ms       -56.7%   ok
    folder_cascade.list_depth8                      p50   0.3ms       0.3ms       -8.8%    ok
    folder_cascade.list_depth8                      p95   0.6ms       0.5ms       -22.2%   ok
    folder_cascade.list_depth8                      p99   1.9ms       0.5ms       -71.5%   ok
    folder_cascade.list_depth_deep                  p50   0.3ms       0.3ms       -5.0%    ok
    folder_cascade.list_depth_deep                  p95   0.6ms       0.5ms       -18.6%   ok
    folder_cascade.list_depth_deep                  p99   2.0ms       0.7ms       -67.2%   ok
    share_cascade_rebac.list_grants                 p50   0.4ms       0.3ms       -27.6%   ok
    share_cascade_rebac.list_grants                 p95   1.2ms       0.5ms       -57.0%   ok
    share_cascade_rebac.list_grants                 p99   1.7ms       1.1ms       -36.7%   ok
    share_cascade_rebac.fetch_as_grantee_depth1     p50   0.5ms       0.5ms       -11.1%   ok
    share_cascade_rebac.fetch_as_grantee_depth1     p95   1.1ms       0.7ms       -41.2%   ok
    share_cascade_rebac.fetch_as_grantee_depth1     p99   3.0ms       1.3ms       -58.3%   ok
    share_cascade_rebac.fetch_as_grantee_depth4     p50   0.5ms       0.5ms       -13.7%   ok
    share_cascade_rebac.fetch_as_grantee_depth4     p95   1.4ms       0.7ms       -50.5%   ok
    share_cascade_rebac.fetch_as_grantee_depth4     p99   2.2ms       1.1ms       -49.3%   ok
    share_cascade_rebac.fetch_as_grantee_depth8     p50   0.5ms       0.4ms       -16.0%   ok
    share_cascade_rebac.fetch_as_grantee_depth8     p95   0.9ms       0.7ms       -26.1%   ok
    share_cascade_rebac.fetch_as_grantee_depth8     p99   1.5ms       0.9ms       -40.1%   ok
    share_cascade_rebac.fetch_as_grantee_depth_deep p50   0.5ms       0.4ms       -17.3%   ok
    share_cascade_rebac.fetch_as_grantee_depth_deep p95   1.0ms       0.7ms       -31.2%   ok
    share_cascade_rebac.fetch_as_grantee_depth_deep p99   1.6ms       0.8ms       -49.4%   ok
    subject_group_nested.fetch_as_member_depth1     p50   0.5ms       0.4ms       -8.4%    ok
    subject_group_nested.fetch_as_member_depth1     p95   0.6ms       0.6ms       -10.2%   ok
    subject_group_nested.fetch_as_member_depth1     p99   1.4ms       0.6ms       -55.2%   ok
    subject_group_nested.fetch_as_member_depth4     p50   0.5ms       0.5ms       -7.9%    ok
    subject_group_nested.fetch_as_member_depth4     p95   0.6ms       0.6ms       -4.0%    ok
    subject_group_nested.fetch_as_member_depth4     p99   0.7ms       0.6ms       -2.2%    ok
    subject_group_nested.fetch_as_member_depth8     p50   0.5ms       0.4ms       -8.9%    ok
    subject_group_nested.fetch_as_member_depth8     p95   0.5ms       0.6ms       +7.1%    ok
    subject_group_nested.fetch_as_member_depth8     p99   0.6ms       0.7ms       +10.3%   ok
    subject_group_nested.fetch_as_member_depth_deep p50   0.5ms       0.4ms       -7.6%    ok
    subject_group_nested.fetch_as_member_depth_deep p95   0.6ms       0.5ms       -11.9%   ok
    subject_group_nested.fetch_as_member_depth_deep p99   0.6ms       0.7ms       +8.9%    ok
This commit is contained in:
Edouard Vanbelle
2026-06-14 16:42:58 +02:00
parent 1d0e4d3c42
commit 0ef4c624c5
26 changed files with 2345 additions and 15 deletions
+746
View File
@@ -0,0 +1,746 @@
//! `load-seed` — bulk-seeds OxiCloud's test database for K6 load scenarios.
//!
//! Inserts users, a deep folder tree, files (all sharing one dedup'd blob),
//! nested subject groups, and ReBAC grants directly via sqlx — bypassing the
//! REST API so the seed phase is fast and doesn't pollute measured metrics.
//!
//! Only the resources each k6 scenario actively touches (the grant being
//! created, the move target, etc.) go through the HTTP API at run time.
//!
//! Run (from repo root, against the test DB on port 5433):
//!
//! ```bash
//! DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \
//! cargo run --bin load-seed -- \
//! --depth 8 --fanout 3 --files-per-leaf 3 \
//! --extra-users 20 --group-depth 3 --group-fanout 5 \
//! --manifest tests/load/results/seed-manifest.json
//! ```
//!
//! Output: writes a JSON manifest at `--manifest` listing the IDs and
//! credentials each k6 scenario needs (admin login, grantee user, deep folder
//! IDs, group chain, etc.). K6 scenarios load this manifest at startup.
use argon2::password_hash::SaltString;
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
use rand_core::OsRng;
use serde::Serialize;
use sqlx::PgPool;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
// ── CLI ──────────────────────────────────────────────────────────────────
struct Args {
depth: u32,
fanout: u32,
files_per_leaf: u32,
extra_users: u32,
group_depth: u32,
group_fanout: u32,
manifest: PathBuf,
password: String,
}
impl Args {
fn parse() -> Self {
// Defaults match tests/load/test.env; both bound the exponential cost
// of fanout^depth. Override via flags or LOAD_* env vars.
let mut depth = 5;
let mut fanout = 4;
let mut files_per_leaf = 3;
let mut extra_users = 20;
let mut group_depth = 3;
let mut group_fanout = 5;
let mut manifest = PathBuf::from("tests/load/results/seed-manifest.json");
let mut password = "TestPassword1!".to_string();
let argv: Vec<String> = env::args().collect();
let mut i = 1;
while i < argv.len() {
let key = argv[i].as_str();
let val = || {
argv.get(i + 1)
.unwrap_or_else(|| panic!("missing value for {}", key))
};
match key {
"--depth" => depth = val().parse().expect("--depth must be u32"),
"--fanout" => fanout = val().parse().expect("--fanout must be u32"),
"--files-per-leaf" => {
files_per_leaf = val().parse().expect("--files-per-leaf must be u32");
}
"--extra-users" => {
extra_users = val().parse().expect("--extra-users must be u32");
}
"--group-depth" => {
group_depth = val().parse().expect("--group-depth must be u32");
}
"--group-fanout" => {
group_fanout = val().parse().expect("--group-fanout must be u32");
}
"--manifest" => manifest = PathBuf::from(val()),
"--password" => password = val().clone(),
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => panic!("unknown argument: {}", other),
}
i += 2;
}
Self {
depth,
fanout,
files_per_leaf,
extra_users,
group_depth,
group_fanout,
manifest,
password,
}
}
}
fn print_help() {
println!("load-seed — bulk-seed OxiCloud for k6 load tests");
println!();
println!("Reads DATABASE_URL from env. Inserts users, folders, files, groups, grants.");
println!("Writes a JSON manifest the k6 scenarios consume.");
println!();
println!("Flags (with defaults):");
println!(" --depth 5 folder tree depth (exponential: fanout^depth folders)");
println!(" --fanout 4 children per non-leaf folder");
println!(" --files-per-leaf 3 files inserted in each leaf folder");
println!(" --extra-users 20 non-admin users created");
println!(" --group-depth 3 nested subject-group chain length");
println!(" --group-fanout 5 users added directly to each leaf group");
println!(" --manifest <path> output manifest JSON path");
println!(" --password <pw> shared password for all seeded users");
}
// ── Manifest (consumed by k6 scenarios) ──────────────────────────────────
#[derive(Serialize)]
struct Manifest {
admin: UserCreds,
grantee: UserCreds,
group_member: UserCreds,
shared_subtree: SubtreeIds,
group_subtree: SubtreeIds,
nested_groups: NestedGroupIds,
/// Total folders seeded (informational).
total_folders: u64,
/// Total files seeded (informational).
total_files: u64,
}
#[derive(Serialize)]
struct UserCreds {
id: Uuid,
username: String,
password: String,
}
#[derive(Serialize)]
struct SubtreeIds {
/// Root of the subtree the grant is attached to.
root: Uuid,
/// A folder at depth 4 inside the subtree (for mid-depth fetches).
depth4: Uuid,
/// A folder at depth 8 inside the subtree (for mid-depth fetches).
depth8: Uuid,
/// A folder at depth N (== `--depth`) inside the subtree (for deep fetches).
deepest: Uuid,
}
#[derive(Serialize)]
struct NestedGroupIds {
/// Outermost group — the one referenced by the grant. Contains `mid` as a member.
root: Uuid,
/// Intermediate group — member of `root`, contains `leaf` as a member.
mid: Uuid,
/// Innermost group — member of `mid`, contains `group_member` user as a direct member.
leaf: Uuid,
}
// ── Main ─────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Best-effort: load .env if present (mirrors the rest of the codebase).
let _ = dotenvy::dotenv();
let args = Args::parse();
let database_url = env::var("DATABASE_URL").map_err(|_| {
"DATABASE_URL must be set (e.g. postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test)"
})?;
println!(
"[load-seed] connecting to {}",
sanitize_url_for_log(&database_url)
);
let pool = PgPool::connect(&database_url).await?;
println!("[load-seed] wiping previous load-test data…");
wipe(&pool).await?;
println!("[load-seed] hashing shared password…");
let password_hash = hash_password(&args.password);
println!(
"[load-seed] inserting users (1 admin + {})…",
args.extra_users
);
let admin = insert_user(&pool, "load_admin", "admin", &password_hash).await?;
let extra_users = insert_extra_users(&pool, args.extra_users, &password_hash).await?;
// Pick a grantee (user[0]) and a group member (user[1]).
let grantee = extra_users
.first()
.ok_or("need at least one extra user for grantee")?
.clone();
let group_member = extra_users
.get(1)
.ok_or("need at least two extra users")?
.clone();
println!("[load-seed] inserting shared blob…");
let blob_hash = insert_shared_blob(&pool).await?;
println!(
"[load-seed] building folder tree (depth={}, fanout={})…",
args.depth, args.fanout
);
// Two parallel subtrees off root: one for the user-grant scenario, one for
// the group-grant scenario. Each has its own depth/fanout shape so a single
// grant cascades over a known fixed number of descendants.
let shared_subtree =
build_subtree(&pool, admin.id, "shared_root", args.depth, args.fanout).await?;
let group_subtree =
build_subtree(&pool, admin.id, "group_root", args.depth, args.fanout).await?;
let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64;
let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len();
println!(
"[load-seed] inserting files (files_per_leaf={}, leaves={})…",
args.files_per_leaf, total_leaves
);
let total_files = insert_files(
&pool,
admin.id,
&blob_hash,
&shared_subtree.leaves,
&group_subtree.leaves,
args.files_per_leaf,
)
.await?;
println!(
"[load-seed] building nested group chain (depth={}, fanout={})…",
args.group_depth, args.group_fanout
);
let nested_groups = build_group_chain(
&pool,
admin.id,
&extra_users,
&group_member.id,
args.group_depth,
args.group_fanout,
)
.await?;
println!("[load-seed] inserting grants…");
// Grant the grantee read on shared_subtree.root.
insert_grant(
&pool,
"user",
grantee.id,
"folder",
shared_subtree.root,
"read",
admin.id,
)
.await?;
// Grant the outermost group read on group_subtree.root.
insert_grant(
&pool,
"group",
nested_groups.root,
"folder",
group_subtree.root,
"read",
admin.id,
)
.await?;
let manifest = Manifest {
admin: UserCreds {
id: admin.id,
username: admin.username,
password: args.password.clone(),
},
grantee: UserCreds {
id: grantee.id,
username: grantee.username,
password: args.password.clone(),
},
group_member: UserCreds {
id: group_member.id,
username: group_member.username,
password: args.password.clone(),
},
shared_subtree: SubtreeIds {
root: shared_subtree.root,
depth4: shared_subtree.depth4,
depth8: shared_subtree.depth8,
deepest: shared_subtree.deepest,
},
group_subtree: SubtreeIds {
root: group_subtree.root,
depth4: group_subtree.depth4,
depth8: group_subtree.depth8,
deepest: group_subtree.deepest,
},
nested_groups,
total_folders,
total_files,
};
if let Some(parent) = args.manifest.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&manifest)?;
fs::write(&args.manifest, json)?;
println!(
"[load-seed] manifest written → {} ({} folders, {} files)",
args.manifest.display(),
total_folders,
total_files
);
Ok(())
}
// ── Wipe ─────────────────────────────────────────────────────────────────
async fn wipe(pool: &PgPool) -> Result<(), sqlx::Error> {
// Users starting with `load_` are the only ones this seeder ever creates.
// CASCADE on auth.users → storage.folders / storage.files / grants / group
// memberships, so a single DELETE wipes everything load-test-related.
sqlx::query("DELETE FROM auth.users WHERE username LIKE 'load_%'")
.execute(pool)
.await?;
// Groups starting with `load_` aren't owned by users; clear them explicitly.
sqlx::query("DELETE FROM auth.subject_groups WHERE name LIKE 'load_%'")
.execute(pool)
.await?;
// The shared dedup blob is identified by its fixed hash sentinel below.
sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(())
}
// ── Password ─────────────────────────────────────────────────────────────
/// Light Argon2id parameters — same shape as the password_hasher test path
/// (`src/infrastructure/services/password_hasher.rs::test_hasher`).
/// Production hashes verify regardless of m/t/p because Argon2 reads the
/// parameters back from the hash string itself.
fn hash_password(password: &str) -> String {
let params = Params::new(16384, 1, 1, None).expect("valid argon2 params");
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let salt = SaltString::generate(&mut OsRng);
argon2
.hash_password(password.as_bytes(), &salt)
.expect("argon2 hash")
.to_string()
}
// ── Users ────────────────────────────────────────────────────────────────
#[derive(Clone)]
struct SeededUser {
id: Uuid,
username: String,
}
async fn insert_user(
pool: &PgPool,
username: &str,
role: &str,
password_hash: &str,
) -> Result<SeededUser, sqlx::Error> {
let email = format!("{}@load.test.invalid", username);
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
VALUES ($1, $2, $3, $4::auth.userrole)
RETURNING id",
)
.bind(username)
.bind(email)
.bind(password_hash)
.bind(role)
.fetch_one(pool)
.await?;
Ok(SeededUser {
id: row.0,
username: username.to_string(),
})
}
async fn insert_extra_users(
pool: &PgPool,
count: u32,
password_hash: &str,
) -> Result<Vec<SeededUser>, sqlx::Error> {
if count == 0 {
return Ok(Vec::new());
}
let usernames: Vec<String> = (0..count).map(|i| format!("load_user_{:04}", i)).collect();
let emails: Vec<String> = usernames
.iter()
.map(|u| format!("{}@load.test.invalid", u))
.collect();
// Bulk insert via UNNEST — one round-trip for all N users.
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
SELECT u.username, u.email, $1::text, 'user'::auth.userrole
FROM UNNEST($2::text[], $3::text[]) AS u(username, email)
RETURNING id, username",
)
.bind(password_hash)
.bind(&usernames)
.bind(&emails)
.fetch_all(pool)
.await?;
// RETURNING order is implementation-defined — sort by username so the
// caller can rely on `extra_users[0]` being `load_user_0000`.
let mut users: Vec<SeededUser> = rows
.into_iter()
.map(|(id, username)| SeededUser { id, username })
.collect();
users.sort_by(|a, b| a.username.cmp(&b.username));
Ok(users)
}
// ── Blob ─────────────────────────────────────────────────────────────────
/// All seeded files share this single zero-byte blob. Its ref_count is bumped
/// in lockstep with file inserts; the file_metadata schema doesn't require a
/// real on-disk blob for the dedup index to be consistent.
const SHARED_BLOB_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
async fn insert_shared_blob(pool: &PgPool) -> Result<String, sqlx::Error> {
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count, content_type)
VALUES ($1, 0, 0, 'application/octet-stream')
ON CONFLICT (hash) DO NOTHING",
)
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(SHARED_BLOB_HASH.to_string())
}
// ── Folder tree ──────────────────────────────────────────────────────────
struct Subtree {
root: Uuid,
depth4: Uuid,
depth8: Uuid,
deepest: Uuid,
all_ids: Vec<Uuid>,
leaves: Vec<Uuid>,
}
/// Build a folder tree under `parent=NULL` rooted at `root_name`.
///
/// Inserts level-by-level so `trg_folders_path` resolves `path`/`lpath` from
/// the already-committed parent rows. Returns the root, a depth-4 sample, the
/// deepest leaf, the full id set, and the leaf ids (used for file seeding).
async fn build_subtree(
pool: &PgPool,
user_id: Uuid,
root_name: &str,
depth: u32,
fanout: u32,
) -> Result<Subtree, sqlx::Error> {
// Per-row BEFORE INSERT trigger (compute_folder_path) makes each level cost
// O(parents × fanout); past depth 6 with fanout 5 the seed time grows fast.
// Print the count BEFORE each level so it's clear which level is in flight
// and how many rows are about to be inserted.
let predicted: u64 = (0..=depth).map(|l| (fanout as u64).pow(l)).sum();
println!(
" subtree '{}': ~{} folders predicted",
root_name, predicted
);
// Level 0 — the root folder.
let root: (Uuid,) = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
RETURNING id",
)
.bind(root_name)
.bind(user_id)
.fetch_one(pool)
.await?;
let root_id = root.0;
let mut all_ids = vec![root_id];
let mut current_level: Vec<Uuid> = vec![root_id];
let mut depth4: Option<Uuid> = None;
let mut depth8: Option<Uuid> = None;
let mut deepest: Uuid = root_id;
for level in 1..=depth {
// Build the (parent_id, name) pairs for this level.
let parents: Vec<Uuid> = current_level
.iter()
.flat_map(|p| std::iter::repeat_n(*p, fanout as usize))
.collect();
let names: Vec<String> = current_level
.iter()
.enumerate()
.flat_map(|(pi, _)| (0..fanout).map(move |c| format!("l{}_{}_{}", level, pi, c)))
.collect();
let started = std::time::Instant::now();
println!(
" level {}/{}: inserting {} folders…",
level,
depth,
parents.len()
);
let rows: Vec<(Uuid,)> = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
SELECT f.name, f.parent_id, $1
FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name)
RETURNING id",
)
.bind(user_id)
.bind(&parents)
.bind(&names)
.fetch_all(pool)
.await?;
let level_ids: Vec<Uuid> = rows.into_iter().map(|(id,)| id).collect();
println!(
" level {}/{}: done in {:.1}s",
level,
depth,
started.elapsed().as_secs_f64()
);
if level == 4 {
depth4 = level_ids.first().copied();
}
if level == 8 {
depth8 = level_ids.first().copied();
}
if level == depth {
deepest = *level_ids.first().expect("non-empty deepest level");
}
all_ids.extend(&level_ids);
current_level = level_ids;
}
let depth4 = depth4.unwrap_or(deepest);
let depth8 = depth8.unwrap_or(deepest);
let leaves = current_level;
Ok(Subtree {
root: root_id,
depth4,
depth8,
deepest,
all_ids,
leaves,
})
}
// ── Files ────────────────────────────────────────────────────────────────
async fn insert_files(
pool: &PgPool,
user_id: Uuid,
blob_hash: &str,
shared_leaves: &[Uuid],
group_leaves: &[Uuid],
files_per_leaf: u32,
) -> Result<u64, sqlx::Error> {
if files_per_leaf == 0 {
return Ok(0);
}
let all_leaves: Vec<Uuid> = shared_leaves
.iter()
.chain(group_leaves.iter())
.copied()
.collect();
if all_leaves.is_empty() {
return Ok(0);
}
// Build (folder_id, name) pairs.
let folder_ids: Vec<Uuid> = all_leaves
.iter()
.flat_map(|f| std::iter::repeat_n(*f, files_per_leaf as usize))
.collect();
let names: Vec<String> = (0..all_leaves.len())
.flat_map(|li| (0..files_per_leaf).map(move |fi| format!("file_{}_{}.txt", li, fi)))
.collect();
sqlx::query(
"INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
SELECT f.name, f.folder_id, $1, $2, 0, 'text/plain'
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)",
)
.bind(user_id)
.bind(blob_hash)
.bind(&folder_ids)
.bind(&names)
.execute(pool)
.await?;
let total = folder_ids.len() as i64;
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + $1 WHERE hash = $2")
.bind(total)
.bind(blob_hash)
.execute(pool)
.await?;
Ok(total as u64)
}
// ── Subject groups ───────────────────────────────────────────────────────
async fn build_group_chain(
pool: &PgPool,
admin_id: Uuid,
extra_users: &[SeededUser],
group_member_user_id: &Uuid,
depth: u32,
fanout: u32,
) -> Result<NestedGroupIds, Box<dyn std::error::Error>> {
if depth < 2 {
return Err(format!("--group-depth must be ≥ 2 (got {})", depth).into());
}
// Create `depth` groups: g_0 (outermost) → g_1 → … → g_{depth-1} (innermost).
// Insert one row per group; CITEXT name must match the RFC-5321 regex,
// and the `load_` prefix lets the wipe step find them again.
let salt: String = Uuid::new_v4().simple().to_string();
let names: Vec<String> = (0..depth)
.map(|i| format!("load_g_{}_{}", salt, i))
.collect();
let mut group_ids: HashMap<u32, Uuid> = HashMap::with_capacity(depth as usize);
for (i, name) in names.iter().enumerate() {
let row: (Uuid,) =
sqlx::query_as("INSERT INTO auth.subject_groups (name) VALUES ($1) RETURNING id")
.bind(name)
.fetch_one(pool)
.await?;
group_ids.insert(i as u32, row.0);
}
// Wire the chain: g_i contains g_{i+1} as a member (XOR: member_group_id set).
for i in 0..(depth - 1) {
let parent = group_ids[&i];
let child = group_ids[&(i + 1)];
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
VALUES ($1, NULL, $2, $3)",
)
.bind(parent)
.bind(child)
.bind(admin_id)
.execute(pool)
.await?;
}
// Innermost group gets `fanout` direct user members, ensuring the
// group_member sentinel user is always among them.
let leaf = group_ids[&(depth - 1)];
let user_ids: Vec<Uuid> = {
let mut v = vec![*group_member_user_id];
for u in extra_users.iter().filter(|u| u.id != *group_member_user_id) {
if v.len() as u32 >= fanout {
break;
}
v.push(u.id);
}
v
};
let leafs: Vec<Uuid> = std::iter::repeat_n(leaf, user_ids.len()).collect();
let added_by_col: Vec<Uuid> = std::iter::repeat_n(admin_id, user_ids.len()).collect();
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
SELECT g.group_id, g.member_user_id, NULL, g.added_by
FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[]) AS g(group_id, member_user_id, added_by)",
)
.bind(&leafs)
.bind(&user_ids)
.bind(&added_by_col)
.execute(pool)
.await?;
Ok(NestedGroupIds {
root: group_ids[&0],
mid: group_ids[&(depth / 2)],
leaf,
})
}
// ── Grants ───────────────────────────────────────────────────────────────
async fn insert_grant(
pool: &PgPool,
subject_type: &str,
subject_id: Uuid,
resource_type: &str,
resource_id: Uuid,
permission: &str,
granted_by: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) DO NOTHING",
)
.bind(subject_type)
.bind(subject_id)
.bind(resource_type)
.bind(resource_id)
.bind(permission)
.bind(granted_by)
.execute(pool)
.await?;
Ok(())
}
// ── Utilities ────────────────────────────────────────────────────────────
fn sanitize_url_for_log(url: &str) -> String {
// Strip "user:password@" from the URL so logs don't leak credentials.
if let Some(at) = url.find('@')
&& let Some(scheme_end) = url.find("://")
{
let scheme = &url[..scheme_end + 3];
let rest = &url[at..];
return format!("{}***{}", scheme, rest);
}
url.to_string()
}