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
@@ -23,16 +23,33 @@ use crate::domain::entities::subject_group::{
use crate::domain::repositories::subject_group_repository::{
SubjectGroupRepository, SubjectGroupRepositoryError,
};
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
use crate::infrastructure::repositories::pg::{SubjectGroupPgRepository, UserPgRepository};
pub struct SubjectGroupService {
repo: Arc<SubjectGroupPgRepository>,
pool: Arc<PgPool>,
/// Looked up by `add_member` to refuse external-user candidates.
/// External users are grant-only recipients; placing them in a
/// subject group would let any later group-grant on internal
/// resources silently leak access to them. `UserPgRepository` rather
/// than `Arc<dyn UserStoragePort>` because the port's `async fn`s
/// make it not dyn-compatible (matches the convention used by other
/// services in this layer).
user_storage: Arc<UserPgRepository>,
}
impl SubjectGroupService {
pub fn new(repo: Arc<SubjectGroupPgRepository>, pool: Arc<PgPool>) -> Self {
Self { repo, pool }
pub fn new(
repo: Arc<SubjectGroupPgRepository>,
pool: Arc<PgPool>,
user_storage: Arc<UserPgRepository>,
) -> Self {
Self {
repo,
pool,
user_storage,
}
}
/// Create a new group. Validates the name (RFC 5321 local-part shape)
@@ -249,6 +266,39 @@ impl SubjectGroupService {
));
}
// Refuse external-user candidates. External users are grant-only
// recipients; placing one in a subject group would let any later
// group-grant on an internal resource silently leak access.
// Mirrors the no-external-admins enforcement style in
// `User::new_external`.
if let GroupMember::User(uid) = member {
match UserRepository::get_user_by_id(&*self.user_storage, uid).await {
Ok(user) if user.is_external() => {
tracing::info!(
target: "audit",
event = "group.external_member_rejected",
group_id = %group_id,
user_id = %uid,
by = %caller_id,
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"SubjectGroup",
"External users cannot be members of subject groups; share resources with them directly".to_string(),
));
}
Ok(_) => { /* internal user — proceed */ }
Err(UserRepositoryError::NotFound(_)) => {
return Err(DomainError::new(
ErrorKind::NotFound,
"SubjectGroup",
format!("user {} not found", uid),
));
}
Err(e) => return Err(DomainError::from(e)),
}
}
self.repo
.add_member(group_id, member, caller_id)
.await
@@ -409,7 +459,8 @@ mod integration_tests {
ensure_clean_test_db(&pool).await;
let pool = Arc::new(pool);
let repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
SubjectGroupService::new(repo, pool)
let user_storage = Arc::new(UserPgRepository::new(pool.clone()));
SubjectGroupService::new(repo, pool, user_storage)
}
async fn first_admin(pool: &sqlx::PgPool) -> Uuid {
@@ -520,6 +571,62 @@ mod integration_tests {
assert_eq!(post, 0, "grants must be revoked atomically with the group");
}
// ── External users cannot be added as subject group members ─────────────
//
// Defense-in-depth gap #1 closed in PR 6: external users (grant-only
// recipients) must never appear inside a subject group, because the
// group could later be granted access to internal resources.
#[tokio::test]
async fn test_external_user_cannot_be_added_as_member() {
let svc = make_service().await;
let admin = first_admin(&svc.pool).await;
// Insert an external user directly (no public test helper for this yet).
let external_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO auth.users (
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, active, is_external
) VALUES ($1, $2, $3, '__EXTERNAL_NO_PASSWORD__', 'user'::auth.userrole,
0, 0, NOW(), NOW(), TRUE, TRUE)",
)
.bind(external_id)
.bind(format!("ext-{}@example.com", &external_id.to_string()[..8]))
.bind(format!("ext-{}@example.com", &external_id.to_string()[..8]))
.execute(svc.pool.as_ref())
.await
.expect("seed external user");
let group = svc
.create(&rand_name("ext-reject"), None, admin)
.await
.unwrap();
let err = svc
.add_member(group.id, GroupMember::User(external_id), admin)
.await
.expect_err("external user must be rejected as a group member");
assert_eq!(err.kind, ErrorKind::AccessDenied);
assert!(
err.message.contains("External users"),
"error message should explain the rejection; got: {}",
err.message
);
// Verify the membership did NOT land in the table.
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM auth.subject_group_members
WHERE group_id = $1 AND member_user_id = $2",
)
.bind(group.id)
.bind(external_id)
.fetch_one(svc.pool.as_ref())
.await
.unwrap();
assert_eq!(count, 0, "external user must not appear in members table");
}
// Bonus: service-layer name validation runs before the DB round-trip.
#[tokio::test]
async fn test_service_rejects_invalid_name_locally() {