feat(user): add given_name/family_name auth.users

reflect OIDC
  schema migration, User entity additions, and three defense-in-depth gaps closed, with all 280 unit tests and 13 Hurl files green
  infrastructure (lettre + EmailSender port).

  - Migration migrations/20260612000003_users_username_email_login.sql — adds nullable given_name/family_name columns to auth.users.
  - User entity (src/domain/entities/user.rs) — has_login_credential() placeholder-check encapsulation, set_username revalidating setter, given/family-name fields + getters/setters, validate_username widened 32→254 and now accepts email shape.
  from_data_full extended with two new params; all 7 callsites in user_pg_repository.rs updated.
  - Schema-side legacy guards (src/application/services/auth_application_service.rs) — bumped the duplicated 32-char check in setup_create_admin and admin_create_user to 254 to match.
  - Gap #1 (subject_group_service.rs) — add_member now rejects external candidates with an audit-logged AccessDenied. Service gained an Arc<UserPgRepository> field, wired through DI. New integration test test_external_user_cannot_be_added_as_member.
  - Gap #2 (user_repository.rs + auth_ports.rs + user_pg_repository.rs) — list_users/search_users gained an include_external: bool param defaulting effectively to false everywhere internal-user-facing. auth_application_service exposes a new
  list_users_including_external for the admin surface.
  - Gap #3 (pg_acl_engine.rs) — expand_user now SELECTs is_external and skips INTERNAL_GROUP_ID for externals; defaults to is_external=true on missing user to fail closed.
This commit is contained in:
Edouard Vanbelle
2026-06-01 20:37:36 +02:00
parent ce25bfa209
commit 5fab0532dc
10 changed files with 399 additions and 45 deletions
@@ -95,10 +95,11 @@ impl UserRepository for UserPgRepository {
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, is_external
oidc_provider, oidc_subject, is_external,
given_name, family_name
) VALUES (
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
$12, $13, $14
$12, $13, $14, $15, $16
)
RETURNING *
"#,
@@ -117,6 +118,8 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.oidc_provider())
.bind(user_clone.oidc_subject())
.bind(user_clone.is_external())
.bind(user_clone.given_name())
.bind(user_clone.family_name())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -140,7 +143,8 @@ impl UserRepository for UserPgRepository {
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
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
FROM auth.users
WHERE id = $1
"#,
@@ -173,6 +177,8 @@ impl UserRepository for UserPgRepository {
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
))
}
@@ -184,7 +190,8 @@ impl UserRepository for UserPgRepository {
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
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
FROM auth.users
WHERE username = $1
"#,
@@ -217,6 +224,8 @@ impl UserRepository for UserPgRepository {
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
))
}
@@ -228,7 +237,8 @@ impl UserRepository for UserPgRepository {
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
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
FROM auth.users
WHERE email = $1
"#,
@@ -261,6 +271,8 @@ impl UserRepository for UserPgRepository {
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
))
}
@@ -285,7 +297,9 @@ impl UserRepository for UserPgRepository {
updated_at = $8,
last_login_at = $9,
active = $10,
image = $11
image = $11,
given_name = $12,
family_name = $13
WHERE id = $1
"#,
)
@@ -300,6 +314,8 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.last_login_at())
.bind(user_clone.is_active())
.bind(user_clone.image())
.bind(user_clone.given_name())
.bind(user_clone.family_name())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -359,21 +375,29 @@ impl UserRepository for UserPgRepository {
}
/// Lists users with pagination
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>> {
async fn list_users(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> UserRepositoryResult<Vec<User>> {
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
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.bind(include_external)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
@@ -404,6 +428,8 @@ impl UserRepository for UserPgRepository {
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
)
})
.collect();
@@ -411,7 +437,12 @@ impl UserRepository for UserPgRepository {
Ok(users)
}
async fn search_users(&self, query: &str, limit: i64) -> UserRepositoryResult<Vec<User>> {
async fn search_users(
&self,
query: &str,
limit: i64,
include_external: bool,
) -> UserRepositoryResult<Vec<User>> {
let pattern = format!("%{}%", query);
let rows = sqlx::query(
r#"
@@ -419,15 +450,18 @@ impl UserRepository for UserPgRepository {
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
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
FROM auth.users
WHERE username ILIKE $1 OR email ILIKE $1
WHERE (username ILIKE $1 OR email ILIKE $1)
AND ($3 OR is_external = FALSE)
ORDER BY username
LIMIT $2
"#,
)
.bind(&pattern)
.bind(limit)
.bind(include_external)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
@@ -457,6 +491,8 @@ impl UserRepository for UserPgRepository {
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
)
})
.collect();
@@ -543,7 +579,8 @@ impl UserRepository for UserPgRepository {
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
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
FROM auth.users
WHERE role::text = $1
ORDER BY created_at DESC
@@ -580,6 +617,8 @@ impl UserRepository for UserPgRepository {
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
)
})
.collect();
@@ -615,7 +654,8 @@ impl UserRepository for UserPgRepository {
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
oidc_provider, oidc_subject, image, is_external,
given_name, family_name
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
"#,
@@ -648,6 +688,8 @@ impl UserRepository for UserPgRepository {
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
))
}
@@ -757,14 +799,24 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError> {
UserRepository::list_users(self, limit, offset)
async fn list_users(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> Result<Vec<User>, DomainError> {
UserRepository::list_users(self, limit, offset, include_external)
.await
.map_err(DomainError::from)
}
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError> {
UserRepository::search_users(self, query, limit)
async fn search_users(
&self,
query: &str,
limit: i64,
include_external: bool,
) -> Result<Vec<User>, DomainError> {
UserRepository::search_users(self, query, limit, include_external)
.await
.map_err(DomainError::from)
}
+29 -5
View File
@@ -127,7 +127,16 @@ impl PgAclEngine {
/// Expand a user subject into the set of subject UUIDs that should match
/// in `access_grants`: the user's own UUID, every group the user is
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
/// transitively a member of, and (for internal users only) the implicit
/// `INTERNAL_GROUP_ID`.
///
/// External users (`auth.users.is_external = TRUE`) do NOT belong to
/// the Internal virtual group — they are grant-only recipients whose
/// access is determined exclusively by explicit grants on their
/// `user_id` or on subject groups they were explicitly added to.
/// `SubjectGroupService::add_member` rejects externals, so the only
/// path by which an external user reaches a resource is via a
/// `subject_type='user'` grant.
///
/// This is the **only** place transitive membership is walked. A future
/// closure-table swap-in (Option 3 in the design doc) replaces just the
@@ -147,10 +156,25 @@ impl PgAclEngine {
let mut set: HashSet<Uuid> = HashSet::new();
set.insert(user_id);
// The Internal virtual group: implicit membership for every
// authenticated user. Once the external-users work lands this will
// narrow to `if !user.is_external { ... }`.
set.insert(INTERNAL_GROUP_ID);
// Look up `is_external` for the caller — external users do not
// belong to the Internal virtual group. Unknown user (no row) is
// treated as external to fail closed: a deleted or bogus user_id
// must not gain implicit Internal membership.
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let is_external: bool =
sqlx::query_scalar("SELECT is_external FROM auth.users WHERE id = $1")
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("PgAcl", format!("lookup is_external: {e}"))
})?
.unwrap_or(true);
if !is_external {
set.insert(INTERNAL_GROUP_ID);
}
if let Some(repo) = &self.group_repo {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);