[M3] mxaccess-galaxy: GalaxyUserProfile + UserResolver trait + role-blob
Lands the user-resolver half of M3 stream A. Pure-Rust foundation — the tiberius-backed SQL impl is logged as F14 and stays gated behind the existing galaxy-resolver Cargo feature. New - role_blob.rs (~270 LoC, 12 tests including a garbage-between-roles edge case) — port of ParseRoleBlob (cs:87-133). Sliding-window scan over hex-decoded UTF-16LE bytes; rejects non-printable code units; case-insensitive dedup. Pure function, no I/O. - user.rs (~290 LoC, 8 tests including 4 tokio-driven InMemoryUserResolver cases) — GalaxyUserProfile (port of cs:5-11) + from_columns helper bridging into role_blob + UserResolver async trait + UserResolverError with NotFound / Backend variants. - sql.rs additions: USER_SELECT_SQL + USER_BY_GUID_SQL + USER_BY_NAME_SQL constants (port of cs:135-148). Inline concatcp! macro composes the base SELECT with each WHERE clause at compile time without pulling const_format. Cargo.toml: added uuid (Galaxy user_guid is a uniqueidentifier). design/followups.md: added F14 (P2) for the tiberius-backed SQL impl behind the galaxy-resolver feature. Test count delta: 427 -> 446 (+19; mxaccess-galaxy 30 -> 49). All four DoD gates green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
//! `GalaxyUserProfile` + async `UserResolver` trait.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeClient/GalaxyRepositoryUserResolver.cs`.
|
||||
//! The .NET reference exposes a single concrete class with a SQL
|
||||
//! backend; the Rust port splits that into a trait + the data type +
|
||||
//! a separate role-blob parser ([`crate::role_blob::parse_role_blob`])
|
||||
//! so consumers can plug in any backend (in-memory cache, JSON snapshot,
|
||||
//! REST client, planned `tiberius`-gated SQL impl).
|
||||
//!
|
||||
//! The user resolver is needed by F13's `WriteSecured*` flows — those
|
||||
//! pass `current_user_id` and `verifier_user_id` to identify who
|
||||
//! authorised a security-classified write. The user IDs are
|
||||
//! `dbo.user_profile.user_profile_id` (`int`), looked up either by
|
||||
//! `user_guid` (`uniqueidentifier`) or by `user_profile_name`.
|
||||
|
||||
use crate::role_blob::parse_role_blob;
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Resolved user profile. Field order and types match the .NET
|
||||
/// `GalaxyUserProfile` record exactly (`GalaxyRepositoryUserResolver.cs:5-11`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GalaxyUserProfile {
|
||||
pub user_profile_id: i32,
|
||||
pub user_profile_name: String,
|
||||
pub user_guid: Uuid,
|
||||
pub default_security_group: String,
|
||||
/// `None` when `dbo.user_profile.intouch_access_level IS NULL`
|
||||
/// (`cs:83`).
|
||||
pub intouch_access_level: Option<i32>,
|
||||
/// Role names parsed from the `roles` `varbinary` column via
|
||||
/// [`parse_role_blob`]. `Vec` (not `HashSet`) because the .NET
|
||||
/// reference returns an `IReadOnlyList<string>` preserving discovery
|
||||
/// order; deduplication is case-insensitive (`cs:124`) and happens
|
||||
/// inside the parser.
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
impl GalaxyUserProfile {
|
||||
/// Build a `GalaxyUserProfile` from raw column values, parsing the
|
||||
/// `roles_text` blob through [`parse_role_blob`]. Mirrors
|
||||
/// `ReadProfile` (`GalaxyRepositoryUserResolver.cs:76-85`).
|
||||
///
|
||||
/// `roles_text = None` corresponds to `reader.IsDBNull(5)` at `cs:84`
|
||||
/// — yields an empty role list.
|
||||
#[must_use]
|
||||
pub fn from_columns(
|
||||
user_profile_id: i32,
|
||||
user_profile_name: String,
|
||||
user_guid: Uuid,
|
||||
default_security_group: String,
|
||||
intouch_access_level: Option<i32>,
|
||||
roles_text: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_profile_id,
|
||||
user_profile_name,
|
||||
user_guid,
|
||||
default_security_group,
|
||||
intouch_access_level,
|
||||
roles: roles_text.map(parse_role_blob).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised by [`UserResolver`] implementations. Mirrors
|
||||
/// `KeyNotFoundException` at `cs:48,70` and the same `Backend` /
|
||||
/// pluggable-error split as [`crate::resolver::ResolverError`].
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum UserResolverError {
|
||||
/// No user matched the supplied `user_guid` or `user_profile_name`.
|
||||
/// Mirrors the `KeyNotFoundException` at `cs:48` / `:70`.
|
||||
#[error("Galaxy user '{key}' was not found in dbo.user_profile")]
|
||||
NotFound { key: String },
|
||||
|
||||
/// Backend-specific failure (SQL connect / query error, etc.).
|
||||
#[error("Galaxy user resolver backend error: {message}")]
|
||||
Backend { message: String },
|
||||
}
|
||||
|
||||
/// Pluggable async user-profile resolver.
|
||||
///
|
||||
/// Implementations should be thread-safe (`Send + Sync`) so a single
|
||||
/// resolver can be shared across the high-level write helpers in
|
||||
/// `mxaccess-nmx` and the M4 `Session` façade.
|
||||
#[async_trait]
|
||||
pub trait UserResolver: Send + Sync {
|
||||
/// Look up a user profile by GUID. Mirrors `ResolveByGuidAsync`
|
||||
/// (`GalaxyRepositoryUserResolver.cs:34-52`).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`].
|
||||
async fn resolve_by_guid(
|
||||
&self,
|
||||
user_guid: Uuid,
|
||||
) -> Result<GalaxyUserProfile, UserResolverError>;
|
||||
|
||||
/// Look up a user profile by name. Mirrors `ResolveByNameAsync`
|
||||
/// (`cs:54-74`).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`UserResolverError::NotFound`] or [`UserResolverError::Backend`].
|
||||
async fn resolve_by_name(
|
||||
&self,
|
||||
user_name: &str,
|
||||
) -> Result<GalaxyUserProfile, UserResolverError>;
|
||||
|
||||
/// Convenience: look up the user profile id only. Mirrors
|
||||
/// `ResolveUserProfileIdByGuidAsync` (`cs:26-32`). Default impl
|
||||
/// delegates to [`Self::resolve_by_guid`].
|
||||
///
|
||||
/// # Errors
|
||||
/// As for [`Self::resolve_by_guid`].
|
||||
async fn resolve_user_profile_id_by_guid(
|
||||
&self,
|
||||
user_guid: Uuid,
|
||||
) -> Result<i32, UserResolverError> {
|
||||
let profile = self.resolve_by_guid(user_guid).await?;
|
||||
Ok(profile.user_profile_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic
|
||||
)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn from_columns_handles_null_intouch_and_null_roles() {
|
||||
let p = GalaxyUserProfile::from_columns(
|
||||
42,
|
||||
"TestUser".to_string(),
|
||||
Uuid::nil(),
|
||||
"Default".to_string(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(p.user_profile_id, 42);
|
||||
assert_eq!(p.user_profile_name, "TestUser");
|
||||
assert_eq!(p.user_guid, Uuid::nil());
|
||||
assert_eq!(p.default_security_group, "Default");
|
||||
assert_eq!(p.intouch_access_level, None);
|
||||
assert!(p.roles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_columns_parses_roles_via_parse_role_blob() {
|
||||
// Encode "Owner" + 00 00 + tail terminator manually.
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
for c in "Owner".chars() {
|
||||
let cu = c as u16;
|
||||
bytes.push((cu & 0xFF) as u8);
|
||||
bytes.push((cu >> 8) as u8);
|
||||
}
|
||||
bytes.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let mut hex = String::from("0x");
|
||||
for b in &bytes {
|
||||
hex.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
|
||||
let p = GalaxyUserProfile::from_columns(
|
||||
7,
|
||||
"OwnerUser".to_string(),
|
||||
Uuid::nil(),
|
||||
"Default".to_string(),
|
||||
Some(9999),
|
||||
Some(&hex),
|
||||
);
|
||||
assert_eq!(p.intouch_access_level, Some(9999));
|
||||
assert_eq!(p.roles, vec!["Owner".to_string()]);
|
||||
}
|
||||
|
||||
/// Tiny in-memory implementation for tests — proves the trait is
|
||||
/// implementable without any SQL machinery (mirrors the Resolver
|
||||
/// trait's InMemoryResolver test at resolver.rs).
|
||||
struct InMemoryUserResolver {
|
||||
by_guid: HashMap<Uuid, GalaxyUserProfile>,
|
||||
by_name: HashMap<String, GalaxyUserProfile>,
|
||||
}
|
||||
|
||||
impl InMemoryUserResolver {
|
||||
fn with_one(profile: GalaxyUserProfile) -> Self {
|
||||
let mut by_guid = HashMap::new();
|
||||
by_guid.insert(profile.user_guid, profile.clone());
|
||||
let mut by_name = HashMap::new();
|
||||
by_name.insert(profile.user_profile_name.clone(), profile);
|
||||
Self { by_guid, by_name }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserResolver for InMemoryUserResolver {
|
||||
async fn resolve_by_guid(
|
||||
&self,
|
||||
user_guid: Uuid,
|
||||
) -> Result<GalaxyUserProfile, UserResolverError> {
|
||||
self.by_guid
|
||||
.get(&user_guid)
|
||||
.cloned()
|
||||
.ok_or_else(|| UserResolverError::NotFound {
|
||||
key: user_guid.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_by_name(
|
||||
&self,
|
||||
user_name: &str,
|
||||
) -> Result<GalaxyUserProfile, UserResolverError> {
|
||||
self.by_name
|
||||
.get(user_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| UserResolverError::NotFound {
|
||||
key: user_name.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_profile() -> GalaxyUserProfile {
|
||||
GalaxyUserProfile::from_columns(
|
||||
7,
|
||||
"TestUser".to_string(),
|
||||
Uuid::from_bytes([0xCC; 16]),
|
||||
"Default".to_string(),
|
||||
Some(9999),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn in_memory_resolver_by_guid_round_trip() {
|
||||
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||
let p = r
|
||||
.resolve_by_guid(Uuid::from_bytes([0xCC; 16]))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(p.user_profile_id, 7);
|
||||
assert_eq!(p.user_profile_name, "TestUser");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn in_memory_resolver_by_name_round_trip() {
|
||||
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||
let p = r.resolve_by_name("TestUser").await.unwrap();
|
||||
assert_eq!(p.user_profile_id, 7);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn in_memory_resolver_not_found_by_guid() {
|
||||
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||
let err = r.resolve_by_guid(Uuid::nil()).await.unwrap_err();
|
||||
assert!(matches!(err, UserResolverError::NotFound { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn in_memory_resolver_not_found_by_name() {
|
||||
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||
let err = r.resolve_by_name("DoesNotExist").await.unwrap_err();
|
||||
assert!(matches!(err, UserResolverError::NotFound { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn resolve_user_profile_id_by_guid_default_impl_works() {
|
||||
let r = InMemoryUserResolver::with_one(sample_profile());
|
||||
let id = r
|
||||
.resolve_user_profile_id_by_guid(Uuid::from_bytes([0xCC; 16]))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(id, 7);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user