crates/nornir-identity-directory/src/model.rs
//! **The directory's rows and the store seam** (A34 M-6): what a directory
//! user and group are, and the one trait a product implements over its own
//! storage. Every call is scoped to ONE account: the product builds a store
//! for the account a SCIM bearer or an admin names, with the room, the actor
//! and the clock of that call inside it.
/// A directory user as the shared code sees it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct User {
/// The employee's principal id (the SCIM `id`).
pub principal_id: String,
/// The normalized user name ([`crate::names::normalize_user_name`]).
pub user_name: String,
pub display_name: String,
pub email: String,
/// The IdP's own id for the person (SCIM `externalId`).
pub external_id: String,
pub active: bool,
pub created_at_unix_ms: i64,
/// Removed from the directory (SCIM DELETE): kept gated, never listed.
pub deleted: bool,
}
/// Where a directory user came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Scim,
Csv,
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::Scim => "scim",
Source::Csv => "csv",
}
}
}
/// A NEW employee from the directory (a SCIM create, a CSV row).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewUser {
pub user_name: String,
pub display_name: String,
pub email: String,
pub external_id: String,
pub active: bool,
pub source: Source,
}
/// A change to a directory user; `None` leaves a field.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UserPatch {
pub user_name: Option<String>,
pub display_name: Option<String>,
pub email: Option<String>,
pub external_id: Option<String>,
pub active: Option<bool>,
}
/// A SCIM group, kept as the directory sent it; its members are the
/// account's own principal ids.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Group {
pub id: String,
pub display_name: String,
pub external_id: String,
pub members: Vec<String>,
pub created_at_unix_ms: i64,
}
/// A refusal the store made, by kind (each is one SCIM status).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreError {
/// 404.
NotFound(String),
/// 409 `uniqueness`: the user name is taken.
AlreadyExists(String),
/// 400 `invalidValue`.
Invalid(String),
/// 409: no room for a new employee, an id that cannot be had.
Precondition(String),
/// 403.
Denied(String),
/// 429.
Exhausted(String),
/// 500.
Internal(String),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (StoreError::NotFound(m)
| StoreError::AlreadyExists(m)
| StoreError::Invalid(m)
| StoreError::Precondition(m)
| StoreError::Denied(m)
| StoreError::Exhausted(m)
| StoreError::Internal(m)) = self;
f.write_str(m)
}
}
impl std::error::Error for StoreError {}
/// **One account's directory, over the product's storage.**
///
/// A deactivation (an update to `active: false`, a delete) must end the
/// person's sessions in the same write; the shared code names the principal
/// in its answer ([`crate::import::Imported::ended_sessions`], the SCIM
/// effect) so the console signs them out too (A34-9).
pub trait DirectoryStore {
/// The account's directory, deleted rows included.
fn users(&self) -> Result<Vec<User>, StoreError>;
/// Provision a NEW employee; a user name the directory holds is
/// [`StoreError::AlreadyExists`].
fn provision(&self, user: &NewUser) -> Result<User, StoreError>;
/// Change a user. Returns the row and whether `active` changed.
fn update_user(&self, principal_id: &str, patch: &UserPatch)
-> Result<(User, bool), StoreError>;
/// Remove a user from the directory (deactivated, no longer listed).
fn delete_user(&self, principal_id: &str) -> Result<User, StoreError>;
fn groups(&self) -> Result<Vec<Group>, StoreError>;
fn put_group(&self, group: Group) -> Result<Group, StoreError>;
fn remove_group(&self, id: &str) -> Result<bool, StoreError>;
/// A fresh, unguessable group id (the product owns the randomness).
fn new_group_id(&self) -> String;
/// The clock of this call.
fn now_unix_ms(&self) -> i64;
/// **Apply a whole import in ONE write** (A34-17): every create and
/// update of the plan, or none of them. A dry run (`commit: false`) runs
/// the same checks (room for every new employee, free ids, free names)
/// and writes nothing.
#[cfg(feature = "csv")]
fn apply_import(
&self,
plan: &crate::import::ImportPlan,
commit: bool,
) -> Result<(), StoreError>;
}