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:
@@ -0,0 +1,25 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Prelude for magic-link external authentication
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- This migration is purely additive — it lands the schema bits needed by the
|
||||||
|
-- subsequent magic-link work without altering existing rows or behaviour:
|
||||||
|
--
|
||||||
|
-- * `given_name` / `family_name` — optional human-readable identity fields.
|
||||||
|
-- Populated from OIDC standard claims (given_name, family_name) at JIT
|
||||||
|
-- provisioning. External users start with both NULL; either side can be
|
||||||
|
-- filled in later via a profile-edit endpoint.
|
||||||
|
--
|
||||||
|
-- Note on username length: `auth.users.username` is already `TEXT` with no
|
||||||
|
-- DB-level length constraint, so it can already hold the 254-char RFC 5321
|
||||||
|
-- maximum required for email-as-username. The widening happens at the
|
||||||
|
-- entity-level validator (`User::validate_username`), not the schema.
|
||||||
|
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS given_name TEXT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS family_name TEXT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.given_name IS
|
||||||
|
'Optional first/given name. Populated from OIDC standard claim `given_name` at JIT provisioning; settable via profile-edit endpoint. NULL until explicitly set.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.family_name IS
|
||||||
|
'Optional last/family name. Populated from OIDC standard claim `family_name` at JIT provisioning; settable via profile-edit endpoint. NULL until explicitly set.';
|
||||||
@@ -91,11 +91,25 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
|||||||
usage_bytes: i64,
|
usage_bytes: i64,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Lists users with pagination
|
/// Lists users with pagination. `include_external` defaults to `false`
|
||||||
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
|
/// at every call site that surfaces users to other internal users
|
||||||
|
/// (autocomplete, sharee search, etc.); only the admin management UI
|
||||||
|
/// passes `true`. See [`UserRepository::list_users`] for the rationale.
|
||||||
|
async fn list_users(
|
||||||
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
include_external: bool,
|
||||||
|
) -> Result<Vec<User>, DomainError>;
|
||||||
|
|
||||||
/// Searches users by username or email (SQL ILIKE) with a limit.
|
/// Searches users by username or email (SQL ILIKE) with a limit.
|
||||||
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError>;
|
/// See [`list_users`] for the meaning of `include_external`.
|
||||||
|
async fn search_users(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: i64,
|
||||||
|
include_external: bool,
|
||||||
|
) -> Result<Vec<User>, DomainError>;
|
||||||
|
|
||||||
/// Lists users by role (e.g., "admin" or "user")
|
/// Lists users by role (e.g., "admin" or "user")
|
||||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
||||||
|
|||||||
@@ -312,11 +312,11 @@ impl AuthApplicationService {
|
|||||||
password: String,
|
password: String,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<UserDto, DomainError> {
|
||||||
// Validate username
|
// Validate username
|
||||||
if username.len() < 3 || username.len() > 32 {
|
if username.len() < 3 || username.len() > 254 {
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::InvalidInput,
|
ErrorKind::InvalidInput,
|
||||||
"User",
|
"User",
|
||||||
"Username must be between 3 and 32 characters".to_string(),
|
"Username must be between 3 and 254 characters".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,13 +773,30 @@ impl AuthApplicationService {
|
|||||||
Ok(admin_users.len() as i64)
|
Ok(admin_users.len() as i64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists internal users only. External (grant-only) users are filtered
|
||||||
|
/// out so that internal-user surfaces — system address book, OCS
|
||||||
|
/// sharee search, etc. — never expose external identities. Admin
|
||||||
|
/// surfaces that need the full list should call
|
||||||
|
/// [`list_users_including_external`] instead.
|
||||||
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
|
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
|
||||||
let users = self.user_storage.list_users(limit, offset).await?;
|
let users = self.user_storage.list_users(limit, offset, false).await?;
|
||||||
Ok(users.into_iter().map(UserDto::from).collect())
|
Ok(users.into_iter().map(UserDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admin-only: lists users including external (grant-only) recipients.
|
||||||
|
/// Used by the admin user-management UI.
|
||||||
|
pub async fn list_users_including_external(
|
||||||
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<UserDto>, DomainError> {
|
||||||
|
let users = self.user_storage.list_users(limit, offset, true).await?;
|
||||||
|
Ok(users.into_iter().map(UserDto::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches internal users only. See [`list_users`] for the rationale.
|
||||||
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
|
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
|
||||||
let users = self.user_storage.search_users(query, limit).await?;
|
let users = self.user_storage.search_users(query, limit, false).await?;
|
||||||
Ok(users.into_iter().map(UserDto::from).collect())
|
Ok(users.into_iter().map(UserDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,11 +810,11 @@ impl AuthApplicationService {
|
|||||||
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
|
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<UserDto, DomainError> {
|
||||||
// Validate username length
|
// Validate username length
|
||||||
if dto.username.len() < 3 || dto.username.len() > 32 {
|
if dto.username.len() < 3 || dto.username.len() > 254 {
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::InvalidInput,
|
ErrorKind::InvalidInput,
|
||||||
"User",
|
"User",
|
||||||
"Username must be between 3 and 32 characters".to_string(),
|
"Username must be between 3 and 254 characters".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,10 @@ impl StorageUsagePort for StorageUsageService {
|
|||||||
info!("Starting batch update of all users' storage usage");
|
info!("Starting batch update of all users' storage usage");
|
||||||
|
|
||||||
// Get the list of all users
|
// Get the list of all users
|
||||||
let users = self.user_repository.list_users(1000, 0).await?;
|
// include_external=false — external users carry no storage by
|
||||||
|
// construction (DB CHECK `users_external_no_storage`), so there's
|
||||||
|
// nothing to compute for them.
|
||||||
|
let users = self.user_repository.list_users(1000, 0, false).await?;
|
||||||
|
|
||||||
let mut update_tasks = Vec::new();
|
let mut update_tasks = Vec::new();
|
||||||
|
|
||||||
|
|||||||
@@ -23,16 +23,33 @@ use crate::domain::entities::subject_group::{
|
|||||||
use crate::domain::repositories::subject_group_repository::{
|
use crate::domain::repositories::subject_group_repository::{
|
||||||
SubjectGroupRepository, SubjectGroupRepositoryError,
|
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 {
|
pub struct SubjectGroupService {
|
||||||
repo: Arc<SubjectGroupPgRepository>,
|
repo: Arc<SubjectGroupPgRepository>,
|
||||||
pool: Arc<PgPool>,
|
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 {
|
impl SubjectGroupService {
|
||||||
pub fn new(repo: Arc<SubjectGroupPgRepository>, pool: Arc<PgPool>) -> Self {
|
pub fn new(
|
||||||
Self { repo, pool }
|
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)
|
/// 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
|
self.repo
|
||||||
.add_member(group_id, member, caller_id)
|
.add_member(group_id, member, caller_id)
|
||||||
.await
|
.await
|
||||||
@@ -409,7 +459,8 @@ mod integration_tests {
|
|||||||
ensure_clean_test_db(&pool).await;
|
ensure_clean_test_db(&pool).await;
|
||||||
let pool = Arc::new(pool);
|
let pool = Arc::new(pool);
|
||||||
let repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
|
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 {
|
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");
|
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.
|
// Bonus: service-layer name validation runs before the DB round-trip.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_service_rejects_invalid_name_locally() {
|
async fn test_service_rejects_invalid_name_locally() {
|
||||||
|
|||||||
@@ -891,6 +891,11 @@ impl AppServiceFactory {
|
|||||||
crate::application::services::subject_group_service::SubjectGroupService::new(
|
crate::application::services::subject_group_service::SubjectGroupService::new(
|
||||||
subject_group_repo.clone(),
|
subject_group_repo.clone(),
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
|
Arc::new(
|
||||||
|
crate::infrastructure::repositories::pg::UserPgRepository::new(
|
||||||
|
pool.clone(),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ pub struct User {
|
|||||||
/// `application/ports/user_lifecycle.rs`. The DB CHECK constraint
|
/// `application/ports/user_lifecycle.rs`. The DB CHECK constraint
|
||||||
/// `users_external_no_storage` is the schema-level safety net.
|
/// `users_external_no_storage` is the schema-level safety net.
|
||||||
is_external: bool,
|
is_external: bool,
|
||||||
|
/// Optional human-readable first/given name. Populated from OIDC
|
||||||
|
/// standard claim `given_name` at JIT provisioning, or via the
|
||||||
|
/// profile-edit endpoint. External users start with `None`.
|
||||||
|
given_name: Option<String>,
|
||||||
|
/// Optional human-readable last/family name. Populated from OIDC
|
||||||
|
/// standard claim `family_name` at JIT provisioning, or via the
|
||||||
|
/// profile-edit endpoint. External users start with `None`.
|
||||||
|
family_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -53,7 +61,7 @@ impl User {
|
|||||||
/// out of the domain layer.
|
/// out of the domain layer.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `username` - User's username (3-32 characters)
|
/// * `username` - User's username (3-254 characters; may be an email)
|
||||||
/// * `email` - User's email address
|
/// * `email` - User's email address
|
||||||
/// * `password_hash` - Pre-hashed password (from PasswordHasherPort)
|
/// * `password_hash` - Pre-hashed password (from PasswordHasherPort)
|
||||||
/// * `role` - User's role
|
/// * `role` - User's role
|
||||||
@@ -93,6 +101,8 @@ impl User {
|
|||||||
oidc_subject: None,
|
oidc_subject: None,
|
||||||
image: None,
|
image: None,
|
||||||
is_external: false,
|
is_external: false,
|
||||||
|
given_name: None,
|
||||||
|
family_name: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +134,8 @@ impl User {
|
|||||||
oidc_subject: Some(oidc_subject),
|
oidc_subject: Some(oidc_subject),
|
||||||
image: None,
|
image: None,
|
||||||
is_external: false,
|
is_external: false,
|
||||||
|
given_name: None,
|
||||||
|
family_name: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +177,8 @@ impl User {
|
|||||||
oidc_subject: None,
|
oidc_subject: None,
|
||||||
image: None,
|
image: None,
|
||||||
is_external: true,
|
is_external: true,
|
||||||
|
given_name: None,
|
||||||
|
family_name: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +218,8 @@ impl User {
|
|||||||
// sessions take a different path that hydrates from DB via
|
// sessions take a different path that hydrates from DB via
|
||||||
// `from_data_full`.
|
// `from_data_full`.
|
||||||
is_external: false,
|
is_external: false,
|
||||||
|
given_name: None,
|
||||||
|
family_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +240,8 @@ impl User {
|
|||||||
oidc_subject: Option<String>,
|
oidc_subject: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
is_external: bool,
|
is_external: bool,
|
||||||
|
given_name: Option<String>,
|
||||||
|
family_name: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -241,6 +259,8 @@ impl User {
|
|||||||
oidc_subject,
|
oidc_subject,
|
||||||
image,
|
image,
|
||||||
is_external,
|
is_external,
|
||||||
|
given_name,
|
||||||
|
family_name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,16 +329,62 @@ impl User {
|
|||||||
self.is_external
|
self.is_external
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn given_name(&self) -> Option<&str> {
|
||||||
|
self.given_name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn family_name(&self) -> Option<&str> {
|
||||||
|
self.family_name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_image(&mut self, image: Option<String>) {
|
pub fn set_image(&mut self, image: Option<String>) {
|
||||||
self.image = image;
|
self.image = image;
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_given_name(&mut self, given_name: Option<String>) {
|
||||||
|
self.given_name = given_name;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_family_name(&mut self, family_name: Option<String>) {
|
||||||
|
self.family_name = family_name;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutate the username after creation. Runs the same validation as the
|
||||||
|
/// constructor — callers must still ensure uniqueness at the repo
|
||||||
|
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
||||||
|
/// endpoint so a user invited with `username = email` can switch to a
|
||||||
|
/// shorter handle later. The home folder name is NOT renamed: it was
|
||||||
|
/// display text at creation; the folder is owned by `user_id`.
|
||||||
|
pub fn set_username(&mut self, new_username: String) -> UserResult<()> {
|
||||||
|
Self::validate_username(&new_username)?;
|
||||||
|
self.username = new_username;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if this is an OIDC-only user (no password)
|
/// Returns true if this is an OIDC-only user (no password)
|
||||||
pub fn is_oidc_user(&self) -> bool {
|
pub fn is_oidc_user(&self) -> bool {
|
||||||
self.oidc_provider.is_some()
|
self.oidc_provider.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true iff this user has any non-magic-link authentication
|
||||||
|
/// method available — either a real (non-placeholder) password hash,
|
||||||
|
/// or a linked OIDC subject. Magic-link auto-authentication is only
|
||||||
|
/// offered for accounts without any of these.
|
||||||
|
///
|
||||||
|
/// The placeholder-string approach is a known smell; a future
|
||||||
|
/// `auth.user_auth_methods` side-table will replace it. Migrating that
|
||||||
|
/// refactor touches only this method's body — every magic-link
|
||||||
|
/// eligibility check goes through here.
|
||||||
|
pub fn has_login_credential(&self) -> bool {
|
||||||
|
let has_password = self.password_hash != "__EXTERNAL_NO_PASSWORD__"
|
||||||
|
&& self.password_hash != "__OIDC_NO_PASSWORD__";
|
||||||
|
has_password || self.oidc_subject.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the password hash.
|
/// Update the password hash.
|
||||||
///
|
///
|
||||||
/// The new password should be hashed externally using PasswordHasherPort
|
/// The new password should be hashed externally using PasswordHasherPort
|
||||||
@@ -355,15 +421,39 @@ impl User {
|
|||||||
|
|
||||||
// ── Shared validation helpers ──────────────────────────────────────
|
// ── Shared validation helpers ──────────────────────────────────────
|
||||||
|
|
||||||
/// Usernames must be 3-32 chars and contain only ASCII alphanumerics,
|
/// Usernames must be 3-254 chars. Two accepted shapes:
|
||||||
/// hyphens, underscores, and dots. This prevents XSS payloads like
|
///
|
||||||
/// `<img/src=x>` from being stored as usernames.
|
/// - **Traditional**: ASCII alphanumerics, hyphens, underscores, and
|
||||||
|
/// dots. No leading/trailing dot or hyphen. Capped at 254 chars
|
||||||
|
/// (well above the historical 32-char limit, but still safe — the
|
||||||
|
/// real upper bound is RFC 5321's email cap for the email shape).
|
||||||
|
/// - **Email-as-username**: must contain `@` and pass `validate_email`.
|
||||||
|
/// External users created from invite-by-email get their normalized
|
||||||
|
/// email as username; internal users may opt into this if they
|
||||||
|
/// prefer their email as their handle.
|
||||||
|
///
|
||||||
|
/// Both shapes prevent XSS payloads like `<img/src=x>` from being
|
||||||
|
/// stored as usernames — the traditional shape via the explicit
|
||||||
|
/// character set, the email shape via `validate_email`'s rejection of
|
||||||
|
/// `<`, `>`, quotes, whitespace, etc.
|
||||||
fn validate_username(username: &str) -> UserResult<()> {
|
fn validate_username(username: &str) -> UserResult<()> {
|
||||||
if username.len() < 3 || username.len() > 32 {
|
if username.len() < 3 || username.len() > 254 {
|
||||||
return Err(UserError::InvalidUsername(
|
return Err(UserError::InvalidUsername(
|
||||||
"Username must be between 3 and 32 characters".to_string(),
|
"Username must be between 3 and 254 characters".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if username.contains('@') {
|
||||||
|
// Email shape — defer to the email validator (which checks the
|
||||||
|
// forbidden-character set and the local-part / domain structure).
|
||||||
|
return Self::validate_email(username).map_err(|e| match e {
|
||||||
|
UserError::ValidationError(m) => {
|
||||||
|
UserError::InvalidUsername(format!("Invalid email-as-username: {}", m))
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if !username
|
if !username
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||||
|
|||||||
@@ -67,11 +67,28 @@ pub trait UserRepository: Send + Sync + 'static {
|
|||||||
/// Updates the last login date
|
/// Updates the last login date
|
||||||
async fn update_last_login(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
async fn update_last_login(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
||||||
|
|
||||||
/// Lists users with pagination
|
/// Lists users with pagination.
|
||||||
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>>;
|
///
|
||||||
|
/// `include_external` controls whether external (grant-only) users
|
||||||
|
/// appear in the result. Default callers should pass `false` so
|
||||||
|
/// external users stay invisible to internal-user surfaces (system
|
||||||
|
/// address book autocomplete, sharee search, etc.). Only the admin
|
||||||
|
/// management UI should request `true`.
|
||||||
|
async fn list_users(
|
||||||
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
include_external: bool,
|
||||||
|
) -> UserRepositoryResult<Vec<User>>;
|
||||||
|
|
||||||
/// Searches users by username or email (SQL ILIKE) with a limit.
|
/// Searches users by username or email (SQL ILIKE) with a limit.
|
||||||
async fn search_users(&self, query: &str, limit: i64) -> UserRepositoryResult<Vec<User>>;
|
/// See [`list_users`] for the meaning of `include_external`.
|
||||||
|
async fn search_users(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: i64,
|
||||||
|
include_external: bool,
|
||||||
|
) -> UserRepositoryResult<Vec<User>>;
|
||||||
|
|
||||||
/// Activates or deactivates a user
|
/// Activates or deactivates a user
|
||||||
async fn set_user_active_status(&self, user_id: Uuid, active: bool)
|
async fn set_user_active_status(&self, user_id: Uuid, active: bool)
|
||||||
|
|||||||
@@ -95,10 +95,11 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role,
|
id, username, email, password_hash, role,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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 (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||||
$12, $13, $14
|
$12, $13, $14, $15, $16
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
@@ -117,6 +118,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
.bind(user_clone.oidc_provider())
|
.bind(user_clone.oidc_provider())
|
||||||
.bind(user_clone.oidc_subject())
|
.bind(user_clone.oidc_subject())
|
||||||
.bind(user_clone.is_external())
|
.bind(user_clone.is_external())
|
||||||
|
.bind(user_clone.given_name())
|
||||||
|
.bind(user_clone.family_name())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -140,7 +143,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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
|
FROM auth.users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -173,6 +177,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
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,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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
|
FROM auth.users
|
||||||
WHERE username = $1
|
WHERE username = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -217,6 +224,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
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,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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
|
FROM auth.users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -261,6 +271,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
|
row.get("given_name"),
|
||||||
|
row.get("family_name"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +297,9 @@ impl UserRepository for UserPgRepository {
|
|||||||
updated_at = $8,
|
updated_at = $8,
|
||||||
last_login_at = $9,
|
last_login_at = $9,
|
||||||
active = $10,
|
active = $10,
|
||||||
image = $11
|
image = $11,
|
||||||
|
given_name = $12,
|
||||||
|
family_name = $13
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -300,6 +314,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
.bind(user_clone.last_login_at())
|
.bind(user_clone.last_login_at())
|
||||||
.bind(user_clone.is_active())
|
.bind(user_clone.is_active())
|
||||||
.bind(user_clone.image())
|
.bind(user_clone.image())
|
||||||
|
.bind(user_clone.given_name())
|
||||||
|
.bind(user_clone.family_name())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -359,21 +375,29 @@ impl UserRepository for UserPgRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lists users with pagination
|
/// 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(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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
|
FROM auth.users
|
||||||
|
WHERE ($3 OR is_external = FALSE)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $1 OFFSET $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
|
.bind(include_external)
|
||||||
.fetch_all(&*self.pool)
|
.fetch_all(&*self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -404,6 +428,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
|
row.get("given_name"),
|
||||||
|
row.get("family_name"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -411,7 +437,12 @@ impl UserRepository for UserPgRepository {
|
|||||||
Ok(users)
|
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 pattern = format!("%{}%", query);
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -419,15 +450,18 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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
|
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
|
ORDER BY username
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&pattern)
|
.bind(&pattern)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(include_external)
|
||||||
.fetch_all(&*self.pool)
|
.fetch_all(&*self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_sqlx_error)?;
|
.map_err(Self::map_sqlx_error)?;
|
||||||
@@ -457,6 +491,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
|
row.get("given_name"),
|
||||||
|
row.get("family_name"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -543,7 +579,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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
|
FROM auth.users
|
||||||
WHERE role::text = $1
|
WHERE role::text = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -580,6 +617,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
|
row.get("given_name"),
|
||||||
|
row.get("family_name"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -615,7 +654,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
id, username, email, password_hash, role::text as role_text,
|
id, username, email, password_hash, role::text as role_text,
|
||||||
storage_quota_bytes, storage_used_bytes,
|
storage_quota_bytes, storage_used_bytes,
|
||||||
created_at, updated_at, last_login_at, active,
|
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
|
FROM auth.users
|
||||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||||
"#,
|
"#,
|
||||||
@@ -648,6 +688,8 @@ impl UserRepository for UserPgRepository {
|
|||||||
row.get("oidc_subject"),
|
row.get("oidc_subject"),
|
||||||
row.get("image"),
|
row.get("image"),
|
||||||
row.get("is_external"),
|
row.get("is_external"),
|
||||||
|
row.get("given_name"),
|
||||||
|
row.get("family_name"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,14 +799,24 @@ impl UserStoragePort for UserPgRepository {
|
|||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError> {
|
async fn list_users(
|
||||||
UserRepository::list_users(self, limit, offset)
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
include_external: bool,
|
||||||
|
) -> Result<Vec<User>, DomainError> {
|
||||||
|
UserRepository::list_users(self, limit, offset, include_external)
|
||||||
.await
|
.await
|
||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError> {
|
async fn search_users(
|
||||||
UserRepository::search_users(self, query, limit)
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: i64,
|
||||||
|
include_external: bool,
|
||||||
|
) -> Result<Vec<User>, DomainError> {
|
||||||
|
UserRepository::search_users(self, query, limit, include_external)
|
||||||
.await
|
.await
|
||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,16 @@ impl PgAclEngine {
|
|||||||
|
|
||||||
/// Expand a user subject into the set of subject UUIDs that should match
|
/// 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
|
/// 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
|
/// This is the **only** place transitive membership is walked. A future
|
||||||
/// closure-table swap-in (Option 3 in the design doc) replaces just the
|
/// 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();
|
let mut set: HashSet<Uuid> = HashSet::new();
|
||||||
set.insert(user_id);
|
set.insert(user_id);
|
||||||
// The Internal virtual group: implicit membership for every
|
|
||||||
// authenticated user. Once the external-users work lands this will
|
// Look up `is_external` for the caller — external users do not
|
||||||
// narrow to `if !user.is_external { ... }`.
|
// belong to the Internal virtual group. Unknown user (no row) is
|
||||||
set.insert(INTERNAL_GROUP_ID);
|
// 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 {
|
if let Some(repo) = &self.group_repo {
|
||||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|||||||
Reference in New Issue
Block a user