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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user