server/lib/src/identity_host.rs
//! **Holger as an identity host** (IDENTITY-HOST, owner GO 2026-10-27; lane OX-IDH step 3).
//!
//! The identity flow (sign-up, login, invite, redeem, sessions, step-up, logout, deactivate)
//! is edda's `nornir-identity` core, `Identity<H: IdentityHost>`. The ONE product-specific part
//! is the host: where this box sits ([`Site`]), where its people are kept ([`UserStore`]), what
//! roles they hold, where an event is written down, and how a link is mailed. This module is
//! Holger's host and nothing more: no ceremony, no session rule, no invite rule is decided here.
//!
//! * **site**: a sold private Holger (its fleet instance is a company name) and an open build
//! (it sells nothing) are [`Site::Private`], invite only; a product site the ONE shop sells
//! sign-up on (`holger-rs`, `test2-holger-rs`) is [`Site::Root`]. Data, never a code path.
//! * **store**: [`HolgerStore`], one file on the data root (`holger-passkeys/identity.json`,
//! beside the archives, so the twin follows it like every other file there), written whole
//! and atomically under one lock.
//! * **roles**: the declarative `AuthConfig` policy, exactly as every door asks it.
//! * **audit**: Holger's audit chain (`AuditLog`).
//! * **mailer**: monetize's one shared sender where the box was fed a relay ([`HolgerMailer`]);
//! an airgapped box refuses by name (`nornir_identity::NoMail`).
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use nornir_identity::{
Credential, Event, IdentityHost, Invite, Mail, MailKind, Mailer, Revocations, Roles, Session,
StoreError, User, UserState, UserStore,
};
use serde::{Deserialize, Serialize};
/// Where a box sits (edda's), named here for holger-server's boot.
pub use nornir_identity::Site;
use crate::audit::{AuditAction, AuditEvent, AuditLog};
use crate::auth::{AuthConfig, AuthIdentity};
use crate::lock::LockOrRecover as _;
/// The directory under the data root the store lives in.
pub const STORE_DIR: &str = "holger-passkeys";
/// The store's one file.
pub const STORE_FILE: &str = "identity.json";
/// The most sessions kept; past it the ones that end soonest go first.
pub const MAX_SESSIONS: usize = 4096;
/// **Where a box with fleet instance `instance` sits** (pure). A sold private Holger's instance
/// is its company (`room::sold_company`), invite only; a product site's label (`holger-rs`)
/// sells sign-up where the build sells at all (`metered`); an open build sells nothing.
pub fn site_for(instance: &str, metered: bool) -> Site {
let instance = instance.trim();
if !metered || instance.is_empty() || sold_company(instance) {
Site::Private
} else {
Site::Root
}
}
/// `room::sold_company`'s rule (a company name: 1 to 16 of `[a-z0-9]`, a letter first), named
/// here because `room` keeps it behind `metered`.
fn sold_company(instance: &str) -> bool {
let b = instance.as_bytes();
(1..=16).contains(&b.len())
&& b[0].is_ascii_lowercase()
&& b.iter()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
/// **This box's site**, from its manual fleet enrolment (`fleet/fleet.json`, the `enrol`
/// CLI's), else [`Site::Private`]. Never from the passkey host (RA5-1): a shop-laid box's host
/// is its estate's DNS name, which is never a company name, so reading it as a site opened
/// sign-up on every sold private Holger. A shop-laid box's site is what the ONE box fed
/// ([`site_from_feed`], `Holger::estate_site` at the start, [`follow_site`] after it).
pub fn site_of(data_root: Option<&Path>, auth: &AuthConfig) -> Site {
let _ = auth;
#[cfg(feature = "metered")]
{
let enrolled = data_root
.and_then(|root| {
crate::holger_fleet::config::FleetConfig::load(root)
.ok()
.flatten()
})
.map(|c| c.instance.trim().to_owned())
.unwrap_or_default();
site_for(&enrolled, true)
}
#[cfg(not(feature = "metered"))]
{
let _ = data_root;
site_for("", false)
}
}
/// The feed key the ONE box names the estate's declared kind with (monetize-shop-pairing
/// `contract::FEED_VARIANT`, in every box's FIRST feed: the record's variant).
pub const VARIANT_FEED: &str = "estate.variant";
/// The variant an operator site the ONE box laid itself is recorded as (holger-monetize-guest's
/// `site_variant`: `holger-twin-one`); a sold estate is fed its `sold_variant` instead.
pub const SITE_VARIANT: &str = "holger-twin-one";
/// The feed keys the estate the boxes pull as is named under (plain, or the front's offer).
const INSTANCE_FEEDS: [&str; 2] = ["fleet_instance", "offer.front.fleet_instance"];
/// Pure: **the site the ONE box's feed declares** (RA5-1). `estate.variant` is the estate's
/// declared kind: [`Site::Root`] only for an operator site's variant ([`SITE_VARIANT`]) on a
/// build that sells (`metered`), [`Site::Private`] for every other variant. A fed
/// `fleet_instance` that is a company name is a sold estate's, private whatever else is fed;
/// with no variant, the instance decides as [`site_for`] does. `None`: the feed declares
/// nothing (yet). Fails closed: any private signal wins.
pub fn site_from_feed(
feed: &std::collections::BTreeMap<String, String>,
metered: bool,
) -> Option<Site> {
let variant = feed
.get(VARIANT_FEED)
.map(|v| v.trim())
.filter(|v| !v.is_empty());
let instance = INSTANCE_FEEDS
.iter()
.find_map(|k| feed.get(*k))
.map(|v| v.trim())
.filter(|v| !v.is_empty());
match (variant, instance) {
(None, None) => None,
(_, Some(i)) if sold_company(i) => Some(Site::Private),
(Some(v), _) => Some(if metered && v == SITE_VARIANT {
Site::Root
} else {
Site::Private
}),
(None, Some(i)) => Some(site_for(i, metered)),
}
}
/// **Follow the site the feed declares, live** (RA5-1): `core`'s site becomes what the feed
/// declares the moment a feed declares one (a late `fleet_instance`, a refeed); a feed that
/// declares nothing leaves it as it is.
pub fn follow_site(feeds: &dyn crate::late::Feeds, core: SharedIdentity) {
crate::late::on_change(
feeds,
"identity: the estate's site",
|f| site_from_feed(f, cfg!(feature = "metered")),
move |site: &Option<Site>| {
if let Some(site) = *site {
if core.host().site() != site {
log::warn!("identity: the feed declares this box {site:?}; sign-up follows it");
}
core.host().set_site(site);
}
},
);
}
// ─── The store ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct UserRow {
id: String,
display: String,
#[serde(default)]
email: Option<String>,
#[serde(default)]
groups: Vec<String>,
state: String,
created: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct CredentialRow {
/// base64url of the credential id.
id: String,
user: String,
/// base64url of the SEC1 uncompressed key.
public_key: String,
sign_count: u32,
created: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct SessionRow {
id: String,
user: String,
auth_time: i64,
expires: i64,
#[serde(default)]
stepped_up_at: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Tables {
#[serde(default)]
users: Vec<UserRow>,
#[serde(default)]
credentials: Vec<CredentialRow>,
#[serde(default)]
sessions: Vec<SessionRow>,
#[serde(default)]
invites: Vec<Invite>,
#[serde(default)]
revocations: Revocations,
}
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn unb64(text: &str) -> Result<Vec<u8>, StoreError> {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(text)
.map_err(|e| StoreError(format!("the identity store holds a bad key: {e}")))
}
fn state_word(s: UserState) -> &'static str {
match s {
UserState::Pending => "pending",
UserState::Active => "active",
UserState::Deactivated => "deactivated",
}
}
fn state_of(word: &str) -> UserState {
match word {
"active" => UserState::Active,
"pending" => UserState::Pending,
// Anything else is no login: a damaged word never lets a person in.
_ => UserState::Deactivated,
}
}
impl From<&User> for UserRow {
fn from(u: &User) -> Self {
UserRow {
id: u.id.clone(),
display: u.display.clone(),
email: u.email.clone(),
groups: u.groups.clone(),
state: state_word(u.state).to_owned(),
created: u.created,
}
}
}
impl From<&UserRow> for User {
fn from(r: &UserRow) -> Self {
User {
id: r.id.clone(),
display: r.display.clone(),
email: r.email.clone(),
groups: r.groups.clone(),
state: state_of(&r.state),
created: r.created,
}
}
}
impl From<&Session> for SessionRow {
fn from(s: &Session) -> Self {
SessionRow {
id: s.id.clone(),
user: s.user.clone(),
auth_time: s.auth_time,
expires: s.expires,
stepped_up_at: s.stepped_up_at,
}
}
}
impl From<&SessionRow> for Session {
fn from(r: &SessionRow) -> Self {
Session {
id: r.id.clone(),
user: r.user.clone(),
auth_time: r.auth_time,
expires: r.expires,
stepped_up_at: r.stepped_up_at,
}
}
}
fn credential_of(r: &CredentialRow) -> Result<Credential, StoreError> {
Ok(Credential {
id: unb64(&r.id)?,
user: r.user.clone(),
public_key: unb64(&r.public_key)?,
sign_count: r.sign_count,
created: r.created,
})
}
/// **Holger's people, on the data root**: users, passkeys, sessions (by hash), invites (by
/// hash) and the revocation floors, in one file written whole (temporary file, sync, rename,
/// directory sync) under one lock, so a crash leaves the old file or the new one.
///
/// **The file is the truth** (RA5-3): another writer replaces it under a running process (the
/// pulled Recharge, the twin's follow), so every read and every change first looks at the file
/// (one `stat`) and reads it again when it is not the one this store read or wrote last. A
/// change is made over what the file holds now, never over a stale copy; a file that does not
/// read is refused by name and never written over.
pub struct HolgerStore {
path: PathBuf,
tables: Mutex<Held>,
}
/// The tables as last read or written, and which file they were.
struct Held {
tables: Tables,
seen: Option<Stamp>,
}
/// Which file the store's path names now: its inode, size and modification time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Stamp {
ino: u64,
len: u64,
modified: Option<std::time::SystemTime>,
}
fn stamp_of(path: &Path) -> std::io::Result<Option<Stamp>> {
match std::fs::metadata(path) {
Ok(m) => {
#[cfg(unix)]
let ino = std::os::unix::fs::MetadataExt::ino(&m);
#[cfg(not(unix))]
let ino = 0;
Ok(Some(Stamp {
ino,
len: m.len(),
modified: m.modified().ok(),
}))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
/// The tables the file at `path` holds (none: no file), and its stamp.
fn load(path: &Path) -> Result<(Tables, Option<Stamp>), String> {
let seen = stamp_of(path)
.map_err(|e| format!("the identity store {} cannot be read: {e}", path.display()))?;
let tables = match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| format!("the identity store {} does not read: {e}", path.display()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Tables::default(),
Err(e) => {
return Err(format!(
"the identity store {} cannot be read: {e}",
path.display()
))
}
};
Ok((tables, seen))
}
impl std::fmt::Debug for HolgerStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HolgerStore")
.field("path", &self.path)
.finish()
}
}
impl HolgerStore {
/// The store under `data_root` (made on first use). A file that does not read is refused by
/// name: a damaged store is never read as "nobody is here", which would reopen the first
/// admin's invite.
pub fn open(data_root: &Path) -> Result<Self, String> {
let dir = data_root.join(STORE_DIR);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("the identity store {} cannot be made: {e}", dir.display()))?;
let path = dir.join(STORE_FILE);
let (tables, seen) = load(&path)?;
Ok(HolgerStore {
path,
tables: Mutex::new(Held { tables, seen }),
})
}
/// The held tables made the file's again when another writer replaced it since.
fn fresh(&self, held: &mut Held) -> Result<(), StoreError> {
let now = stamp_of(&self.path).map_err(|e| {
StoreError(format!(
"the identity store {} cannot be read: {e}",
self.path.display()
))
})?;
if now != held.seen {
let (tables, seen) = load(&self.path).map_err(StoreError)?;
log::warn!("identity: the store {} was written by another writer (a recharge, the twin's follow): read again", self.path.display());
*held = Held { tables, seen };
}
Ok(())
}
fn save(&self, t: &Tables) -> Result<(), StoreError> {
let fail = |e: std::io::Error| {
StoreError(format!(
"the identity store {} was not written: {e}",
self.path.display()
))
};
let bytes = serde_json::to_vec(t)
.map_err(|e| StoreError(format!("the identity store does not serialize: {e}")))?;
let tmp = self.path.with_extension("json.tmp");
{
use std::io::Write as _;
let mut f = std::fs::File::create(&tmp).map_err(fail)?;
f.write_all(&bytes).map_err(fail)?;
f.sync_all().map_err(fail)?;
}
std::fs::rename(&tmp, &self.path).map_err(fail)?;
if let Some(dir) = self.path.parent() {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
}
/// Change the tables and write them, all under the lock; a write that fails leaves the
/// tables as they were.
fn change<R>(&self, f: impl FnOnce(&mut Tables) -> R) -> Result<R, StoreError> {
let mut held = self.tables.lock_or_recover();
self.fresh(&mut held)?;
let mut next = held.tables.clone();
let r = f(&mut next);
self.save(&next)?;
let seen = stamp_of(&self.path).map_err(|e| {
StoreError(format!(
"the identity store {} cannot be read: {e}",
self.path.display()
))
})?;
*held = Held { tables: next, seen };
Ok(r)
}
fn read<R>(&self, f: impl FnOnce(&Tables) -> R) -> Result<R, StoreError> {
let mut held = self.tables.lock_or_recover();
self.fresh(&mut held)?;
Ok(f(&held.tables))
}
}
/// **The people the store under `data_root` holds**, read without opening it (nothing is made);
/// none when there is no store or it does not read.
pub fn people(data_root: &Path) -> Vec<User> {
std::fs::read(data_root.join(STORE_DIR).join(STORE_FILE))
.ok()
.and_then(|b| serde_json::from_slice::<Tables>(&b).ok())
.map(|t| t.users.iter().map(User::from).collect())
.unwrap_or_default()
}
impl UserStore for HolgerStore {
fn user(&self, id: &str) -> Result<Option<User>, StoreError> {
self.read(|t| t.users.iter().find(|u| u.id == id).map(User::from))
}
fn user_by_email(&self, email: &str) -> Result<Option<User>, StoreError> {
self.read(|t| {
t.users
.iter()
.find(|u| u.email.as_deref() == Some(email))
.map(User::from)
})
}
fn users(&self) -> Result<Vec<User>, StoreError> {
self.read(|t| t.users.iter().map(User::from).collect())
}
fn put_user(&self, user: &User) -> Result<(), StoreError> {
self.change(|t| {
t.users.retain(|u| u.id != user.id);
t.users.push(UserRow::from(user));
})
}
fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError> {
let key = b64(id);
self.read(|t| {
t.credentials
.iter()
.find(|c| c.id == key)
.map(credential_of)
})?
.transpose()
}
fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError> {
self.read(|t| {
t.credentials
.iter()
.filter(|c| c.user == user)
.map(credential_of)
.collect()
})?
}
fn put_credential(&self, c: &Credential) -> Result<(), StoreError> {
let row = CredentialRow {
id: b64(&c.id),
user: c.user.clone(),
public_key: b64(&c.public_key),
sign_count: c.sign_count,
created: c.created,
};
self.change(|t| {
t.credentials.retain(|r| r.id != row.id);
t.credentials.push(row);
})
}
fn session(&self, id: &str) -> Result<Option<Session>, StoreError> {
self.read(|t| t.sessions.iter().find(|s| s.id == id).map(Session::from))
}
fn put_session(&self, session: &Session) -> Result<(), StoreError> {
self.change(|t| {
t.sessions.retain(|s| s.id != session.id);
t.sessions.push(SessionRow::from(session));
if t.sessions.len() > MAX_SESSIONS {
t.sessions.sort_by_key(|s| std::cmp::Reverse(s.expires));
t.sessions.truncate(MAX_SESSIONS);
}
})
}
fn delete_session(&self, id: &str) -> Result<(), StoreError> {
self.change(|t| t.sessions.retain(|s| s.id != id))
}
fn invites(&self) -> Result<Vec<Invite>, StoreError> {
self.read(|t| t.invites.clone())
}
fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError> {
self.change(|t| change(&mut t.invites))
}
fn revocations(&self) -> Result<Revocations, StoreError> {
self.read(|t| t.revocations.clone())
}
fn update_revocations(
&self,
change: &mut dyn FnMut(&mut Revocations),
) -> Result<(), StoreError> {
self.change(|t| change(&mut t.revocations))
}
}
// ─── The mailer ─────────────────────────────────────────────────────────────
/// **How Holger mails a link**: the login page of this box (`<origin>/login?invite=<secret>`,
/// the page's one way in for an invite and a first-login link alike), through the shared
/// sender it was fed.
pub struct HolgerMailer {
origin: String,
}
/// The link a mail carries.
pub fn link(origin: &str, secret: &str) -> String {
format!("{}/login?invite={secret}", origin.trim_end_matches('/'))
}
/// The subject and body of a mail (Holger's words).
pub fn words(kind: MailKind, link: &str) -> (&'static str, String) {
match kind {
MailKind::FirstLogin => ("Your Holger account: register your security key", format!("Open this link to register the security key you will sign in to Holger with:\n\n{link}\n\nThe link works once.\n")),
MailKind::Invite => ("You are invited to a Holger", format!("You were invited to a Holger. Open this link to register your security key:\n\n{link}\n\nThe link works once.\n")),
}
}
impl HolgerMailer {
/// The mailer of the box reached at `origin`: it mails through the postman the process holds
/// at the time of each mail (`sign_in_again::postman`, the fed relay once the pull channel
/// starts), and refuses by name while there is none (an airgapped or open box).
pub fn of(origin: &str) -> Self {
HolgerMailer {
origin: origin.to_owned(),
}
}
}
impl Mailer for HolgerMailer {
fn send(&self, mail: &Mail) -> Result<(), String> {
let (subject, body) = words(mail.kind, &link(&self.origin, &mail.secret));
#[cfg(feature = "metered")]
if let Some(postman) = crate::sign_in_again::postman() {
postman.ready()?;
return postman.post(&crate::holger_fleet::sign_in::SignInMail {
to: mail.to.clone(),
subject: subject.to_owned(),
body,
});
}
let _ = (subject, body);
Err(nornir_identity::NO_MAIL.to_owned())
}
}
// ─── Serving: the one core of this process ─────────────────────────────────
/// The identity core of a Holger.
pub type HolgerIdentity = nornir_identity::Identity<HolgerHost>;
/// It, shared by the gRPC door (`IdentityService`) and the auth seam.
pub type SharedIdentity = Arc<HolgerIdentity>;
/// The relying party of `auth`'s passkey method (`rp_id`, `rp_origin`), if it has one.
pub fn party_of(auth: &AuthConfig) -> Option<(String, String)> {
auth.methods.iter().find_map(|m| match m {
crate::auth::AuthMethodConfig::Passkey {
rp_id, rp_origin, ..
} => Some((
rp_id.trim().to_owned(),
rp_origin.trim().trim_end_matches('/').to_owned(),
)),
_ => None,
})
}
// WWW-CANON: the site's origins live in `crate::auth` (every build has them; the account and
// room links of a build without `auth-passkey` name them too).
pub use crate::auth::{origins_of, site_origin};
/// The core's settings for the relying party `rp_id` at `origin` (and its `www.`, [`origins_of`]).
pub fn config_of(rp_id: &str, origin: &str) -> Result<nornir_identity::Config, String> {
if rp_id
.trim()
.trim_matches(|c| c == '[' || c == ']')
.parse::<std::net::IpAddr>()
.is_ok()
{
return Err(format!(
"the relying party {rp_id:?} is an IP literal; a passkey is scoped to a name"
));
}
let party = format!("{rp_id}={}", origins_of(rp_id, origin).join(","))
.parse::<nornir_identity::RelyingParty>()?;
Ok(nornir_identity::Config {
parties: vec![party],
..nornir_identity::Config::default()
})
}
/// **This box's identity core**, over the store on `data_root`, where `auth` has a passkey
/// method (else `None`: the IdentityService is not served, as the passkey door is not).
pub fn open(
data_root: &Path,
auth: &AuthConfig,
audit: Arc<dyn AuditLog>,
) -> Result<Option<SharedIdentity>, String> {
open_at(data_root, auth, audit, None)
}
/// [`open`] at the site the ONE box fed (`fed`, from the feed: [`site_from_feed`]); `None`: the
/// box's own [`site_of`].
pub fn open_at(
data_root: &Path,
auth: &AuthConfig,
audit: Arc<dyn AuditLog>,
fed: Option<Site>,
) -> Result<Option<SharedIdentity>, String> {
let Some((rp_id, origin)) = party_of(auth) else {
return Ok(None);
};
let config = config_of(&rp_id, &origin)?;
let host = HolgerHost::new(
fed.unwrap_or_else(|| site_of(Some(data_root), auth)),
HolgerStore::open(data_root)?,
auth.clone(),
audit,
Box::new(HolgerMailer::of(&origin)),
);
Ok(Some(Arc::new(nornir_identity::Identity::new(host, config))))
}
/// The key a core is filed under: the policy's passkey session key file, else its relying
/// party (one data root, one key file, one core).
fn live_key(auth: &AuthConfig) -> Option<String> {
auth.methods.iter().find_map(|m| match m {
crate::auth::AuthMethodConfig::Passkey {
session_key_file: Some(f),
..
} if !f.trim().is_empty() => Some(f.trim().to_owned()),
crate::auth::AuthMethodConfig::Passkey { rp_id, .. } => Some(rp_id.trim().to_owned()),
_ => None,
})
}
/// The cores this process serves (held for its life), by the key file or relying party their
/// policy names, so the one
/// `auth::validate_request` every door calls finds the core whose session a token is.
fn live_cores() -> &'static std::sync::RwLock<Vec<(String, SharedIdentity)>> {
static LIVE: std::sync::OnceLock<std::sync::RwLock<Vec<(String, SharedIdentity)>>> =
std::sync::OnceLock::new();
LIVE.get_or_init(Default::default)
}
/// Make `core` the one [`session_identity`] asks for tokens under `auth`'s relying party.
pub fn register_live(auth: &AuthConfig, core: &SharedIdentity) {
use crate::lock::RwLockOrRecover as _;
let Some(key) = live_key(auth) else { return };
let mut live = live_cores().write_or_recover();
live.retain(|(k, _)| k != &key);
live.push((key, Arc::clone(core)));
}
/// The core this process serves under `auth`'s passkey method, if any.
pub fn live(auth: &AuthConfig) -> Option<SharedIdentity> {
use crate::lock::RwLockOrRecover as _;
let key = live_key(auth)?;
live_cores()
.read_or_recover()
.iter()
.find(|(k, _)| *k == key)
.map(|(_, c)| Arc::clone(c))
}
/// **The identity a core session token acts as now**: its person with the groups they hold
/// now, or `None` (no such session, ended, expired, under a floor, the person not active, or
/// no core serves this policy here).
pub fn session_identity(auth: &AuthConfig, token: &str) -> Option<AuthIdentity> {
let core = live(auth)?;
let view = core.session(token).ok()?;
let user = core.host().store().user(&view.user_id).ok()??;
Some(identity_of(&user))
}
/// Is `bearer` a core session token (the shape, not its truth)?
pub fn is_session_token(bearer: &str) -> bool {
bearer.starts_with(nornir_identity::TOKEN_PREFIX)
}
// ─── The host ───────────────────────────────────────────────────────────────
/// The clock a host tells time by (Unix milliseconds).
pub type Clock = Arc<dyn Fn() -> i64 + Send + Sync>;
/// **Holger's identity host.**
pub struct HolgerHost {
/// Live: the feed may declare it after the start ([`follow_site`]).
site: std::sync::RwLock<Site>,
store: HolgerStore,
auth: AuthConfig,
audit: Arc<dyn AuditLog>,
mailer: Box<dyn Mailer>,
clock: Option<Clock>,
}
impl std::fmt::Debug for HolgerHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HolgerHost")
.field("site", &self.site())
.field("store", &self.store)
.finish()
}
}
impl HolgerHost {
/// The host over `store`, the policy `auth`, the audit chain and a mailer
/// (`nornir_identity::NoMail` on an airgapped box).
pub fn new(
site: Site,
store: HolgerStore,
auth: AuthConfig,
audit: Arc<dyn AuditLog>,
mailer: Box<dyn Mailer>,
) -> Self {
HolgerHost {
site: std::sync::RwLock::new(site),
store,
auth,
audit,
mailer,
clock: None,
}
}
/// Where this box sits from now on (the feed declared it: [`follow_site`]).
pub fn set_site(&self, site: Site) {
use crate::lock::RwLockOrRecover as _;
*self.site.write_or_recover() = site;
}
/// The same host telling time by `clock` (a test's).
pub fn with_clock(mut self, clock: Clock) -> Self {
self.clock = Some(clock);
self
}
/// The policy the roles are read from.
pub fn auth(&self) -> &AuthConfig {
&self.auth
}
}
/// The identity a [`User`] acts as under the policy (the shape every door resolves).
pub fn identity_of(user: &User) -> AuthIdentity {
AuthIdentity {
subject: user.id.clone(),
method: "passkey".to_owned(),
groups: user.groups.clone(),
display: Some(user.display.clone()),
}
}
/// What an event is called on the audit chain, who did it, its status and one line of detail.
fn audit_line(event: &Event) -> (String, &'static str, u16, String) {
match event {
Event::SignedUp { user } => (user.clone(), "signed-up", 200, String::new()),
Event::SignUpRefused { code } => {
("anonymous".into(), "sign-up-refused", 403, code.to_string())
}
Event::InviteMinted { invite, by, mailed } => (
by.clone(),
"invite-minted",
200,
format!("invite {invite}, mailed {mailed}"),
),
Event::InviteRedeemed { invite, user } => (
user.clone(),
"invite-redeemed",
200,
format!("invite {invite}"),
),
Event::PasskeyRegistered { user, credential } => (
user.clone(),
"passkey-registered",
200,
format!("credential {credential}"),
),
Event::LoggedIn {
user,
session,
user_verified,
} => (
user.clone(),
"logged-in",
200,
format!("session {session}, user verified {user_verified}"),
),
// nornir-identity 0.2.1 names the person the passkey belonged to: their audit has it.
Event::LoginRefused { code, user } => (
user.clone().unwrap_or_else(|| "anonymous".into()),
"login-refused",
401,
code.to_string(),
),
Event::SteppedUp { user } => (user.clone(), "stepped-up", 200, String::new()),
Event::LoggedOut { user, everywhere } => (
user.clone(),
"logged-out",
200,
format!("everywhere {everywhere}"),
),
Event::Deactivated { user, by } => (by.clone(), "deactivated", 200, format!("user {user}")),
Event::InviteRevoked { invite, by } => (
by.clone(),
"invite-revoked",
200,
format!("invite {invite}"),
),
Event::SessionsEnded { user, by } => {
(by.clone(), "sessions-ended", 200, format!("user {user}"))
}
Event::EveryoneLoggedOut { by } => (by.clone(), "everyone-logged-out", 200, String::new()),
}
}
impl IdentityHost for HolgerHost {
fn site(&self) -> Site {
use crate::lock::RwLockOrRecover as _;
*self.site.read_or_recover()
}
fn store(&self) -> &dyn UserStore {
&self.store
}
fn roles(&self, user: &User) -> Roles {
let role = self.auth.role_for_identity(&identity_of(user));
Roles {
admin: role.can_admin(),
names: vec![crate::passkey::role_word(role).to_owned()],
}
}
fn audit(&self, event: Event) {
let (who, what, status, detail) = audit_line(&event);
let ev = AuditEvent::new(
who,
AuditAction::Other,
"",
format!("identity/{what}"),
"",
status,
0,
)
.with_detail(detail);
if let Err(e) = self.audit.record(ev) {
log::warn!("audit record failed: {e}");
}
}
fn mailer(&self) -> &dyn Mailer {
self.mailer.as_ref()
}
fn now_ms(&self) -> i64 {
match &self.clock {
Some(c) => c(),
None => crate::clock::now_unix_ms(),
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::audit::MemoryAuditLog;
use nornir_identity::conformance::Harness;
use nornir_identity::test_host::Kit;
use std::collections::HashMap;
/// The groups the shipped estate's policy makes an admin of, and a reader of.
pub const ADMINS: &str = "holger-admins";
pub const READERS: &str = "holger-readers";
pub fn policy() -> AuthConfig {
AuthConfig {
group_roles: HashMap::from([
(ADMINS.to_string(), crate::auth::Role::Admin),
(READERS.to_string(), crate::auth::Role::Reader),
]),
..Default::default()
}
}
/// **Holger's conformance harness**: a fresh store on a fresh data root, the kit's mailbox
/// and clock, the shipped policy's groups.
pub struct HolgerHarness;
impl Harness for HolgerHarness {
type Host = HolgerHost;
fn host(&self, site: Site, kit: &Kit) -> HolgerHost {
let root = tempfile::tempdir().unwrap().keep();
let clock = kit.clock.clone();
HolgerHost::new(
site,
HolgerStore::open(&root).unwrap(),
policy(),
Arc::new(MemoryAuditLog::new()),
Box::new(kit.mail.clone()),
)
.with_clock(Arc::new(move || clock.now_ms()))
}
fn admin_groups(&self) -> Vec<String> {
vec![ADMINS.to_owned()]
}
fn member_groups(&self) -> Vec<String> {
vec![READERS.to_owned()]
}
}
/// The core's ONE conformance suite, against Holger's host.
mod conformance {
nornir_identity::conformance_suite!(super::HolgerHarness);
}
/// ★ The site is data: a sold private Holger and an open build are invite only, the
/// shop's product site sells sign-up.
#[test]
fn a_sold_private_holger_and_an_open_build_are_invite_only_and_the_product_site_sells_sign_up()
{
assert_eq!(
site_for("acme", true),
Site::Private,
"a sold private Holger's instance is its company"
);
assert_eq!(
site_for("holger-rs", true),
Site::Root,
"the product site sells sign-up"
);
assert_eq!(site_for("test2-holger-rs", true), Site::Root);
assert_eq!(
site_for("holger-rs", false),
Site::Private,
"an open build sells nothing"
);
assert_eq!(
site_for("", true),
Site::Private,
"a box that does not know where it is sells nothing"
);
}
/// ★ RA5-1: the site is what the ONE box FED: the estate's declared variant (in every box's
/// first feed), a company instance always private, nothing fed nothing declared.
#[test]
fn the_fed_variant_and_instance_declare_the_site_and_private_wins() {
let f = |kv: &[(&str, &str)]| {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<std::collections::BTreeMap<_, _>>()
};
assert_eq!(
site_from_feed(&f(&[]), true),
None,
"nothing fed, nothing declared"
);
assert_eq!(
site_from_feed(&f(&[(VARIANT_FEED, "holger-twin-monetize")]), true),
Some(Site::Private),
"a sold estate's variant"
);
assert_eq!(
site_from_feed(&f(&[(VARIANT_FEED, SITE_VARIANT)]), true),
Some(Site::Root),
"an operator site's variant"
);
assert_eq!(
site_from_feed(&f(&[(VARIANT_FEED, SITE_VARIANT)]), false),
Some(Site::Private),
"an open build sells nothing"
);
assert_eq!(
site_from_feed(
&f(&[(VARIANT_FEED, SITE_VARIANT), ("fleet_instance", "acme")]),
true
),
Some(Site::Private),
"a company instance is private whatever else is fed"
);
assert_eq!(
site_from_feed(&f(&[("offer.front.fleet_instance", "acme")]), true),
Some(Site::Private)
);
assert_eq!(
site_from_feed(&f(&[("fleet_instance", "holger-rs")]), true),
Some(Site::Root),
"no variant: the instance decides"
);
assert_eq!(site_from_feed(&f(&[(VARIANT_FEED, " ")]), true), None);
}
/// ★ RA5-1: the site follows the feed live: a late feed declaring the estate private closes
/// a core that opened as a site, and a feed that declares nothing leaves it.
#[test]
fn the_core_follows_the_site_the_feed_declares() {
struct Bus(Mutex<Vec<crate::late::Take>>);
impl crate::late::Feeds for Bus {
fn subscribe(&self, _: &'static str, take: crate::late::Take) {
take(&Default::default());
self.0.lock().unwrap().push(take);
}
fn now(&self) -> crate::late::Feed {
Default::default()
}
fn door_role(&self) -> Option<Option<String>> {
None
}
}
let root = tempfile::tempdir().unwrap();
let core = open_at(
root.path(),
&served_policy(),
Arc::new(MemoryAuditLog::new()),
Some(Site::Root),
)
.unwrap()
.unwrap();
let bus = Bus(Mutex::new(Vec::new()));
follow_site(&bus, Arc::clone(&core));
assert_eq!(core.host().site(), Site::Root, "nothing declared yet");
let feed: crate::late::Feed =
[(VARIANT_FEED.to_string(), "holger-twin-monetize".to_string())].into();
for t in bus.0.lock().unwrap().iter() {
t(&feed);
}
assert_eq!(
core.host().site(),
Site::Private,
"the late feed declares a sold estate"
);
}
/// ★ The store outlives a restart, and a damaged file is refused by name, never read as
/// "nobody is here".
#[test]
fn the_store_survives_a_restart_and_a_damaged_file_is_refused() {
let root = tempfile::tempdir().unwrap();
let s = HolgerStore::open(root.path()).unwrap();
let ada = User {
id: "u-1".into(),
display: "Ada".into(),
email: Some("ada@mock.invalid".into()),
groups: vec![ADMINS.into()],
state: UserState::Active,
created: 1,
};
s.put_user(&ada).unwrap();
s.put_credential(&Credential {
id: vec![1, 2, 3],
user: "u-1".into(),
public_key: vec![4; 65],
sign_count: 7,
created: 1,
})
.unwrap();
s.update_revocations(&mut |r| r.revoke_subject("u-1", 5))
.unwrap();
let again = HolgerStore::open(root.path()).unwrap();
assert_eq!(again.user("u-1").unwrap(), Some(ada));
assert_eq!(again.credential(&[1, 2, 3]).unwrap().unwrap().sign_count, 7);
assert!(!again.revocations().unwrap().admits("u-1", 5));
std::fs::write(root.path().join(STORE_DIR).join(STORE_FILE), b"{ not json").unwrap();
assert!(HolgerStore::open(root.path())
.unwrap_err()
.contains("does not read"));
}
/// ★ RA5-3: **a recharge that writes the store's file under a running process is read, and
/// the next write keeps every person.** The pulled Recharge (and the twin's follow) write
/// `holger-passkeys/identity.json` with no restart; the process's store must see it, never
/// save its stale tables over it.
#[test]
fn a_recharge_written_under_the_store_is_read_and_a_later_write_keeps_every_person() {
let root = tempfile::tempdir().unwrap();
let person = |id: &str, name: &str| User {
id: id.into(),
display: name.into(),
email: None,
groups: vec![READERS.into()],
state: UserState::Active,
created: 1,
};
// The rebuilt main boots with an empty store, held.
let s = HolgerStore::open(root.path()).unwrap();
assert!(s.users().unwrap().is_empty());
// The recharge writes the twin's people: renamed into place (a new file) ...
let twin = HolgerStore::open(&tempfile::tempdir().unwrap().keep()).unwrap();
twin.put_user(&person("u-ada", "Ada")).unwrap();
twin.put_credential(&Credential {
id: vec![9, 9],
user: "u-ada".into(),
public_key: vec![4; 65],
sign_count: 3,
created: 1,
})
.unwrap();
twin.update_revocations(&mut |r| r.revoke_subject("u-old", 5))
.unwrap();
let file = root.path().join(STORE_DIR).join(STORE_FILE);
let tmp = root.path().join("pulled.json");
std::fs::copy(&twin.path, &tmp).unwrap();
std::fs::rename(&tmp, &file).unwrap();
assert_eq!(
s.user("u-ada").unwrap().map(|u| u.display),
Some("Ada".into()),
"the store reads what the recharge wrote"
);
// ... and the first write after it (an invite, a sign-up, the shop's take-in) keeps them.
s.put_user(&person("u-bo", "Bo")).unwrap();
// A second writer rewrites the file in place (the same file, new bytes).
let mut t: Tables = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
t.users.push(UserRow::from(&person("u-cy", "Cy")));
std::fs::write(&file, serde_json::to_vec(&t).unwrap()).unwrap();
s.put_session(&Session {
id: "sess".into(),
user: "u-bo".into(),
auth_time: 1,
expires: i64::MAX,
stepped_up_at: None,
})
.unwrap();
let again = HolgerStore::open(root.path()).unwrap();
let mut ids: Vec<String> = again.users().unwrap().into_iter().map(|u| u.id).collect();
ids.sort();
assert_eq!(ids, vec!["u-ada", "u-bo", "u-cy"], "every person is kept");
assert_eq!(
again.credential(&[9, 9]).unwrap().map(|c| c.sign_count),
Some(3),
"the passkey is kept"
);
assert!(
!again.revocations().unwrap().admits("u-old", 5),
"the revocation floor is kept"
);
// A file the other writer left damaged is refused by name, never written over.
std::fs::write(&file, b"{ half a file").unwrap();
assert!(s
.put_user(&person("u-dee", "Dee"))
.unwrap_err()
.0
.contains("does not read"));
assert_eq!(std::fs::read(&file).unwrap(), b"{ half a file");
}
/// ★ Roles are the policy's, as every door asks it.
#[test]
fn roles_are_the_policys() {
let h = HolgerHarness.host(Site::Private, &Kit::default());
let user = |g: &str| User {
id: "u".into(),
display: "x".into(),
email: None,
groups: vec![g.into()],
state: UserState::Active,
created: 0,
};
assert_eq!(
h.roles(&user(ADMINS)),
Roles {
admin: true,
names: vec!["admin".into()]
}
);
assert_eq!(
h.roles(&user(READERS)),
Roles {
admin: false,
names: vec!["reader".into()]
}
);
}
/// ★ An identity event lands in Holger's audit chain, and no secret with it.
#[test]
fn an_event_is_written_to_holgers_audit_chain() {
let audit = Arc::new(MemoryAuditLog::new());
let root = tempfile::tempdir().unwrap();
let h = HolgerHost::new(
Site::Private,
HolgerStore::open(root.path()).unwrap(),
policy(),
audit.clone(),
Box::new(nornir_identity::NoMail),
);
h.audit(Event::InviteMinted {
invite: "inv-hash".into(),
by: "u-1".into(),
mailed: false,
});
let ev = audit.events();
assert_eq!(ev.len(), 1);
assert_eq!(
(ev[0].ident.as_str(), ev[0].artifact.as_str(), ev[0].status),
("u-1", "identity/invite-minted", 200)
);
assert_eq!(ev[0].detail, "invite inv-hash, mailed false");
}
/// ★ A refused passkey login is written to the person the passkey named (nornir-identity
/// 0.2.1's `LoginRefused.user`); a passkey nobody here holds stays anonymous.
#[test]
fn a_refused_login_is_in_the_audit_of_the_person_the_passkey_named() {
let audit = Arc::new(MemoryAuditLog::new());
let root = tempfile::tempdir().unwrap();
let h = HolgerHost::new(
Site::Private,
HolgerStore::open(root.path()).unwrap(),
policy(),
audit.clone(),
Box::new(nornir_identity::NoMail),
);
h.audit(Event::LoginRefused {
code: nornir_identity::Code::NotActive,
user: Some("u-1".into()),
});
h.audit(Event::LoginRefused {
code: nornir_identity::Code::Ceremony,
user: None,
});
let ev = audit.events();
assert_eq!(ev.len(), 2);
assert_eq!(
(ev[0].ident.as_str(), ev[0].artifact.as_str(), ev[0].status),
("u-1", "identity/login-refused", 401)
);
assert_eq!(ev[0].detail, nornir_identity::Code::NotActive.to_string());
assert_eq!(
(ev[1].ident.as_str(), ev[1].artifact.as_str(), ev[1].status),
("anonymous", "identity/login-refused", 401)
);
}
/// ★ The link a mail carries is this box's login page, and an open build mails nothing.
#[test]
fn the_mail_carries_the_login_link_and_an_open_build_refuses_by_name() {
assert_eq!(
link("https://holger.mock.invalid/", "s3cret"),
"https://holger.mock.invalid/login?invite=s3cret"
);
let (subject, body) = words(
MailKind::Invite,
&link("https://holger.mock.invalid", "s3cret"),
);
assert!(
subject.contains("Holger")
&& body.contains("https://holger.mock.invalid/login?invite=s3cret")
);
#[cfg(not(feature = "metered"))]
{
let m = HolgerMailer::of("https://holger.mock.invalid");
assert_eq!(
m.send(&Mail {
to: "kim@mock.invalid".into(),
kind: MailKind::Invite,
secret: "s".into()
}),
Err(nornir_identity::NO_MAIL.to_owned())
);
}
}
// ── Serving ──────────────────────────────────────────────────────────────
use nornir_identity::conformance::{redeem, ORIGIN, RP_ID};
use nornir_identity::grpc::pb::identity_service_server::IdentityService as _;
use nornir_identity::grpc::{pb, IdentityGrpc};
use nornir_identity::test_host::TestHost;
/// A policy with a passkey method for the suite's relying party.
fn served_policy() -> AuthConfig {
let mut auth = policy();
auth.methods = vec![crate::auth::AuthMethodConfig::Passkey {
rp_id: RP_ID.into(),
rp_origin: ORIGIN.into(),
rp_name: String::new(),
session_key_file: None,
bootstrap_until_first_admin: true,
}];
auth
}
/// ★ A box with a passkey method serves a core; a box without one serves none.
#[test]
fn a_box_with_a_passkey_method_serves_the_identity_core_and_one_without_serves_none() {
let root = tempfile::tempdir().unwrap();
assert!(
open(root.path(), &policy(), Arc::new(MemoryAuditLog::new()))
.unwrap()
.is_none(),
"no passkey method, no IdentityService"
);
let core = open(
root.path(),
&served_policy(),
Arc::new(MemoryAuditLog::new()),
)
.unwrap()
.expect("a passkey method serves the core");
// The site is the data the box holds: an open build sells nothing, and a box that was
// neither enrolled nor fed a site is invite only (RA5-1: never by its passkey host).
assert_eq!(
core.host().site(),
Site::Private,
"a box that does not know where it is sells nothing"
);
let fed = open_at(
tempfile::tempdir().unwrap().path(),
&served_policy(),
Arc::new(MemoryAuditLog::new()),
Some(Site::Root),
)
.unwrap()
.unwrap();
assert_eq!(fed.host().site(), Site::Root, "the site the ONE box fed");
assert_eq!(core.config().parties.len(), 1);
assert!(
config_of("10.0.0.7", "https://10.0.0.7").is_err(),
"an IP literal is no relying party"
);
}
/// ★ A core session is a credential on every door (`auth::validate_request`): its person
/// with the groups they hold, and refused the moment it ends.
#[tokio::test]
async fn a_core_session_is_a_credential_on_every_door_until_it_ends() {
let root = tempfile::tempdir().unwrap();
let auth = served_policy();
let core = open(root.path(), &auth, Arc::new(MemoryAuditLog::new()))
.unwrap()
.unwrap();
register_live(&auth, &core);
let first = core
.first_admin_invite(vec![ADMINS.into()], "installer")
.unwrap();
let (ada, _) = redeem(&core, &first.secret, "Ada", 1);
let id = crate::auth::validate_request(&auth, Some(&ada.token), None)
.await
.unwrap()
.expect("the session is a credential");
assert_eq!(
(id.subject.as_str(), id.method.as_str(), id.groups.clone()),
(
ada.session.user_id.as_str(),
"passkey",
vec![ADMINS.to_string()]
)
);
assert!(auth.role_for_identity(&id).can_admin());
core.logout(&ada.token, false).unwrap();
assert!(
crate::auth::validate_request(&auth, Some(&ada.token), None)
.await
.is_err(),
"an ended session is refused like a forged one"
);
assert!(
crate::auth::validate_request(&auth, Some("nis1.forged"), None)
.await
.is_err()
);
}
/// One scripted walk over the IdentityService adapter, every reply with its ids, tokens and
/// times taken out: what a console sees of a product.
async fn wire_transcript<H: IdentityHost + 'static>(
core: Arc<nornir_identity::Identity<H>>,
kit: &Kit,
admin_groups: Vec<String>,
member_groups: Vec<String>,
) -> Vec<String> {
let svc = IdentityGrpc::new(Arc::clone(&core));
let said = |r: Result<String, tonic::Status>| match r {
Ok(v) => format!("ok {v}").trim_end().to_owned(),
Err(s) => format!(
"refused {:?} {}",
s.code(),
nornir_identity::grpc::refusal_of(&s)
.map(|c| c.as_str())
.unwrap_or("-")
),
};
let info = |s: &pb::SessionInfo| {
format!(
"display={} roles={} admin={} stepped_up={}",
s.display,
s.roles.len(),
s.admin,
s.stepped_up
)
};
let mut out = Vec::new();
let r = svc
.sign_up(tonic::Request::new(pb::SignUpRequest {
email: "kim@mock.invalid".into(),
display: "Kim".into(),
}))
.await;
out.push(format!(
"sign_up: {}",
said(r.map(|_| format!("mails={}", kit.mail.sent().len())))
));
let first = core
.first_admin_invite(admin_groups.clone(), "installer")
.unwrap();
let (ada, mut ada_key) = redeem(&core, &first.secret, "Ada", 1);
// RA5-2 (nornir-identity 0.2.2): an invite needs a fresh step-up.
nornir_identity::conformance::step_up(&core, &ada.token, &mut ada_key)
.expect("ada steps up");
let r = svc
.whoami(tonic::Request::new(pb::WhoamiRequest {
session: ada.token.clone(),
}))
.await;
out.push(format!("whoami: {}", said(r.map(|w| info(w.get_ref())))));
let ask = |groups: Vec<String>, email: &str| pb::CreateInviteRequest {
session: ada.token.clone(),
display: String::new(),
email: email.into(),
groups,
ttl_secs: 0,
person: String::new(),
};
let r = svc
.create_invite(tonic::Request::new(ask(admin_groups.clone(), "")))
.await;
out.push(format!("invite_admin: {}", said(r.map(|_| String::new()))));
let r = svc
.create_invite(tonic::Request::new(ask(
member_groups.clone(),
"bo@mock.invalid",
)))
.await;
let bo_secret = r.as_ref().ok().map(|i| i.get_ref().secret.clone());
out.push(format!(
"invite: {}",
said(r.map(|i| format!(
"mailed={} refusal={}",
i.get_ref().mailed,
!i.get_ref().mail_refusal.is_empty()
)))
));
let r = svc
.redeem_start(tonic::Request::new(pb::RedeemStartRequest {
secret: "not-an-invite".into(),
display: "Eve".into(),
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
client: String::new(),
}))
.await;
out.push(format!(
"redeem_unknown: {}",
said(r.map(|_| String::new()))
));
if let Some(secret) = bo_secret {
let (bo, _) = redeem(&core, &secret, "Bo", 3);
let r = svc
.whoami(tonic::Request::new(pb::WhoamiRequest {
session: bo.token.clone(),
}))
.await;
out.push(format!("bo_whoami: {}", said(r.map(|w| info(w.get_ref())))));
let r = svc
.create_invite(tonic::Request::new(pb::CreateInviteRequest {
session: bo.token.clone(),
..Default::default()
}))
.await;
out.push(format!("bo_invites: {}", said(r.map(|_| String::new()))));
// Past the step-up window: the deactivation below is the unstepped one.
kit.clock.advance(10 * 60 + 1);
let r = svc
.deactivate(tonic::Request::new(pb::DeactivateRequest {
session: ada.token.clone(),
user_id: bo.session.user_id.clone(),
}))
.await;
out.push(format!(
"deactivate_unstepped: {}",
said(r.map(|_| String::new()))
));
let r = svc
.logout(tonic::Request::new(pb::LogoutRequest {
session: bo.token.clone(),
everywhere: true,
}))
.await;
out.push(format!("bo_logout: {}", said(r.map(|_| String::new()))));
let r = svc
.whoami(tonic::Request::new(pb::WhoamiRequest { session: bo.token }))
.await;
out.push(format!(
"bo_after_logout: {}",
said(r.map(|w| info(w.get_ref())))
));
}
let r = svc
.login_start(tonic::Request::new(pb::LoginStartRequest {
rp_id: "evil.mock.invalid".into(),
origin: "https://evil.mock.invalid".into(),
client: String::new(),
}))
.await;
out.push(format!(
"login_elsewhere: {}",
said(r.map(|_| String::new()))
));
let r = svc
.logout(tonic::Request::new(pb::LogoutRequest {
session: ada.token.clone(),
everywhere: false,
}))
.await;
out.push(format!("logout: {}", said(r.map(|_| String::new()))));
let r = svc
.whoami(tonic::Request::new(pb::WhoamiRequest {
session: ada.token,
}))
.await;
out.push(format!(
"after_logout: {}",
said(r.map(|w| info(w.get_ref())))
));
out
}
/// ★ **The diff test**: Holger's IdentityService answers the same scripted steps exactly
/// as the reference host does, on a Root and on a Private site (Gunnar's runs the same walk
/// against the same reference: two products, one set of replies), and the core's own
/// transcript is the reference one (the conformance suite).
#[tokio::test]
async fn holgers_identity_service_answers_as_the_reference_host_does() {
for site in [Site::Root, Site::Private] {
let kit = Kit::default();
let holger = Arc::new(nornir_identity::Identity::new(
HolgerHarness.host(site, &kit),
nornir_identity::conformance::config(),
));
let ours =
wire_transcript(holger, &kit, vec![ADMINS.into()], vec![READERS.into()]).await;
let kit = Kit::default();
let reference = Arc::new(nornir_identity::Identity::new(
TestHost::new(site, &kit),
nornir_identity::conformance::config(),
));
let theirs = wire_transcript(
reference,
&kit,
vec![nornir_identity::test_host::TEST_ADMINS.into()],
vec!["readers".into()],
)
.await;
assert_eq!(
ours, theirs,
"{site:?}: Holger's replies differ from the reference's"
);
assert!(ours.len() >= 10, "{ours:?}");
}
}
}