Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity/src/lib.rs

//! **The identity core: one copy of sign-up, login, invites, sessions and
//! step-up for every nordisk product** (IDENTITY-HOST, owner GO 2026-10-27).
//!
//! [`Identity<H>`] owns the whole flow. A product implements ONE small trait,
//! [`IdentityHost`]: what kind of site it is ([`Site`], data, never a code path),
//! where users live ([`UserStore`]), what its roles are, its audit chain, and how
//! a link reaches a person ([`Mailer`]). Nothing else is the product's: the
//! passkey ceremony is nornir-auth-webauthn's `ceremony`, the invites, floors and
//! step-up rules are nornir-identity-session's, and the rules joining them are
//! here, once.
//!
//! * `grpc`: the `IdentityService` proto and [`grpc::IdentityGrpc`], the one
//!   adapter gunnar-server and holger-server both serve.
//! * `test-support`: [`test_host::TestHost`], a soft passkey, and the
//!   conformance suite ([`conformance`], [`conformance_suite!`]) a product runs
//!   against its own host.
//!
//! A session is an opaque token whose blake3 hash is all the store keeps; it
//! ends on logout, on deactivation (every path, the CSV import's included: they
//! all call [`Identity::deactivate`]), on its expiry and under a revocation floor.

mod flows;
pub use flows::*;

#[cfg(feature = "grpc")]
pub mod grpc;

/// **Company single sign-on** (feature `sso`, A34 M-4): edda nornir-identity-sso,
/// one copy for every product behind this core.
#[cfg(feature = "sso")]
pub use nornir_identity_sso as sso;

#[cfg(feature = "test-support")]
pub mod test_host;

#[cfg(feature = "test-support")]
pub mod conformance;

pub use nornir_auth_webauthn::ceremony::{ClientKey, Limits, RelyingParty};
pub use nornir_identity_session::invites::Invite;
pub use nornir_identity_session::session::Revocations;
pub use nornir_identity_session::step_up::UserVerification;

// ─── The product's data ─────────────────────────────────────────────────────

/// **What kind of site this is**: data the host returns, never a code path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Site {
    /// A site that sells: anybody may sign up.
    Root,
    /// A private install: invite only.
    Private,
}

/// Where a person's account stands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UserState {
    /// Signed up, no passkey yet: cannot log in.
    Pending,
    /// May log in.
    Active,
    /// Ended by an admin or the directory: cannot log in, has no sessions.
    Deactivated,
}

/// A person, as the core reads and writes them through [`UserStore`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    /// The stable id (also the WebAuthn user handle).
    pub id: String,
    /// The name shown.
    pub display: String,
    /// The address a sign-up named, lowercased.
    pub email: Option<String>,
    /// The product's groups; [`IdentityHost::roles`] reads them.
    pub groups: Vec<String>,
    pub state: UserState,
    /// Unix seconds.
    pub created: i64,
}

/// A stored passkey.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credential {
    /// The credential id (rawId).
    pub id: Vec<u8>,
    /// Whose it is.
    pub user: String,
    /// P-256, SEC1 uncompressed.
    pub public_key: Vec<u8>,
    /// The last counter seen.
    pub sign_count: u32,
    /// Unix seconds.
    pub created: i64,
}

/// A session, as the store keeps it: never the token, only its hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
    /// blake3(token), base64url.
    pub id: String,
    pub user: String,
    /// When the login happened, Unix seconds (the floors compare against it).
    pub auth_time: i64,
    /// Unix seconds, exclusive.
    pub expires: i64,
    /// When the person last verified themselves on their own passkey (a
    /// user-verified login counts), Unix seconds.
    pub stepped_up_at: Option<i64>,
}

/// **The product's own roles** for a person.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Roles {
    /// May invite and deactivate.
    pub admin: bool,
    /// The product's role names, shown as they are.
    pub names: Vec<String>,
}

/// **What the core tells the product's audit chain.** No secret is in it: an
/// invite is named by its hash, a session by its hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
    SignedUp { user: String },
    SignUpRefused { code: Code },
    InviteMinted { invite: String, by: String, mailed: bool },
    InviteRedeemed { invite: String, user: String },
    PasskeyRegistered { user: String, credential: String },
    LoggedIn { user: String, session: String, user_verified: bool },
    /// A passkey login was refused. `user` is the person the presented passkey is
    /// registered to, when it named one (a bad signature, a deactivated person), so the
    /// product can write the refusal to that person's account audit; `None` for a passkey
    /// this site does not know or a ceremony refused before the passkey was read (0.2.1).
    LoginRefused { code: Code, user: Option<String> },
    SteppedUp { user: String },
    LoggedOut { user: String, everywhere: bool },
    Deactivated { user: String, by: String },
    /// An open invite was withdrawn (by its id, the secret's hash).
    InviteRevoked { invite: String, by: String },
    /// The host ended every session of one person (a revoked passkey).
    SessionsEnded { user: String, by: String },
    /// A stepped-up admin ended every session on the site.
    EveryoneLoggedOut { by: String },
}

/// The store failed: the product's words.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreError(pub String);

impl std::fmt::Display for StoreError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for StoreError {}

/// **Where a product keeps its people** (Gunnar: its auth tables; Holger: znippy).
/// Every write is the whole record. `update_invites` and `update_revocations`
/// hold the store's lock across the closure, so two racing redemptions cannot
/// both spend one invite.
pub trait UserStore: Send + Sync {
    fn user(&self, id: &str) -> Result<Option<User>, StoreError>;
    fn user_by_email(&self, email: &str) -> Result<Option<User>, StoreError>;
    fn users(&self) -> Result<Vec<User>, StoreError>;
    fn put_user(&self, user: &User) -> Result<(), StoreError>;
    fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError>;
    fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError>;
    fn put_credential(&self, credential: &Credential) -> Result<(), StoreError>;
    fn session(&self, id: &str) -> Result<Option<Session>, StoreError>;
    fn put_session(&self, session: &Session) -> Result<(), StoreError>;
    fn delete_session(&self, id: &str) -> Result<(), StoreError>;
    fn invites(&self) -> Result<Vec<Invite>, StoreError>;
    fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError>;
    fn revocations(&self) -> Result<Revocations, StoreError>;
    fn update_revocations(&self, change: &mut dyn FnMut(&mut Revocations)) -> Result<(), StoreError>;
}

/// What a link is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailKind {
    /// A sign-up's first login: the link registers the first passkey.
    FirstLogin,
    /// An admin's invite.
    Invite,
}

/// One link to one person. The mailer builds the URL from its own base.
#[derive(Clone, PartialEq, Eq)]
pub struct Mail {
    pub to: String,
    pub kind: MailKind,
    /// The single-use secret the link carries.
    pub secret: String,
}

impl std::fmt::Debug for Mail {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Mail").field("to", &self.to).field("kind", &self.kind).field("secret", &"<redacted>").finish()
    }
}

/// **How a link reaches a person.**
pub trait Mailer: Send + Sync {
    /// Send it, or say why not (the product's words).
    fn send(&self, mail: &Mail) -> Result<(), String>;
}

/// **The airgap mailer: it refuses, by name.** A site with no outbound mail
/// hands its invite links over by hand; a sign-up there is refused.
#[derive(Debug, Clone, Copy, Default)]
pub struct NoMail;

/// What [`NoMail`] answers.
pub const NO_MAIL: &str = "this site sends no e-mail (airgap): hand the link over yourself";

impl Mailer for NoMail {
    fn send(&self, _: &Mail) -> Result<(), String> {
        Err(NO_MAIL.to_owned())
    }
}

/// **The ONLY product-specific part of identity.**
pub trait IdentityHost: Send + Sync {
    /// Root (sells sign-up) or Private (invite only).
    fn site(&self) -> Site;
    /// Users, passkeys, sessions, invites, floors.
    fn store(&self) -> &dyn UserStore;
    /// The product's own roles for a person.
    fn roles(&self, user: &User) -> Roles;
    /// The product's audit chain.
    fn audit(&self, event: Event);
    /// First-login and invite links.
    fn mailer(&self) -> &dyn Mailer;
    /// **Whether `by` may mint a passkey link for the existing `person`**,
    /// beside an admin (anybody) and the person themself, who always may: the
    /// product's own rule (Gunnar: an account admin for the account's people).
    /// Default: nobody else.
    fn may_enrol(&self, by: &User, person: &User) -> bool {
        let _ = (by, person);
        false
    }
    /// Unix milliseconds. A test host's clock is its own.
    fn now_ms(&self) -> i64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
            .unwrap_or(0)
    }
}

impl<T: IdentityHost + ?Sized> IdentityHost for std::sync::Arc<T> {
    fn site(&self) -> Site {
        (**self).site()
    }
    fn store(&self) -> &dyn UserStore {
        (**self).store()
    }
    fn roles(&self, user: &User) -> Roles {
        (**self).roles(user)
    }
    fn audit(&self, event: Event) {
        (**self).audit(event)
    }
    fn mailer(&self) -> &dyn Mailer {
        (**self).mailer()
    }
    fn may_enrol(&self, by: &User, person: &User) -> bool {
        (**self).may_enrol(by, person)
    }
    fn now_ms(&self) -> i64 {
        (**self).now_ms()
    }
}

// ─── Refusals ───────────────────────────────────────────────────────────────

/// **A refusal's stable code.** The words are the console's; the code is what
/// two products must agree on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Code {
    /// Sign-up on a Private site.
    SignUpClosed,
    /// The invite or link is unknown, expired, used or revoked (one answer).
    InviteNotOpen,
    /// An invite would make an admin.
    InviteWouldBeAdmin,
    /// The caller is not an admin.
    NotAdmin,
    /// The site already has its first admin.
    Bootstrapped,
    /// A passkey ceremony failed its checks.
    Ceremony,
    /// Too many ceremonies are open (server-wide or for this client).
    Busy,
    /// No such session (unknown, expired, ended or under a floor).
    NoSession,
    /// The person may not log in (pending or deactivated).
    NotActive,
    /// The verb needs a fresh step-up.
    NeedsStepUp,
    /// The mailer refused.
    MailRefused,
    /// The request is malformed.
    Malformed,
    /// The product's store failed.
    Store,
}

impl Code {
    /// The stable name (the gRPC refusal metadata).
    pub fn as_str(self) -> &'static str {
        match self {
            Code::SignUpClosed => "signup_closed",
            Code::InviteNotOpen => "invite_not_open",
            Code::InviteWouldBeAdmin => "invite_would_be_admin",
            Code::NotAdmin => "not_admin",
            Code::Bootstrapped => "bootstrapped",
            Code::Ceremony => "ceremony",
            Code::Busy => "busy",
            Code::NoSession => "no_session",
            Code::NotActive => "not_active",
            Code::NeedsStepUp => "needs_step_up",
            Code::MailRefused => "mail_refused",
            Code::Malformed => "malformed",
            Code::Store => "store",
        }
    }

    /// Every code, for a product mapping its words.
    pub const ALL: [Code; 13] = [
        Code::SignUpClosed,
        Code::InviteNotOpen,
        Code::InviteWouldBeAdmin,
        Code::NotAdmin,
        Code::Bootstrapped,
        Code::Ceremony,
        Code::Busy,
        Code::NoSession,
        Code::NotActive,
        Code::NeedsStepUp,
        Code::MailRefused,
        Code::Malformed,
        Code::Store,
    ];
}

impl std::fmt::Display for Code {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A refusal: its code and the core's detail.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused {
    pub code: Code,
    pub detail: String,
}

impl Refused {
    pub fn new(code: Code, detail: impl Into<String>) -> Self {
        Refused { code, detail: detail.into() }
    }
}

impl std::fmt::Display for Refused {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.code, self.detail)
    }
}

impl std::error::Error for Refused {}

impl From<StoreError> for Refused {
    fn from(e: StoreError) -> Self {
        Refused::new(Code::Store, e.0)
    }
}

// ─── The core's settings ────────────────────────────────────────────────────

/// How long a session lives when the product says nothing: twelve hours.
pub const SESSION_TTL_SECS: i64 = 12 * 3600;

/// The core's settings, the product's to choose.
#[derive(Debug, Clone)]
pub struct Config {
    /// The relying parties this server answers for; empty: the relay's word.
    pub parties: Vec<RelyingParty>,
    /// How long a session lives, seconds.
    pub session_ttl_secs: i64,
    /// Whether a login must be user-verified.
    pub user_verification: UserVerification,
    /// How long an invite or first-login link lives, seconds.
    pub invite_ttl_secs: i64,
    /// The ceremony store's bounds.
    pub limits: Limits,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            parties: Vec::new(),
            session_ttl_secs: SESSION_TTL_SECS,
            user_verification: UserVerification::Preferred,
            invite_ttl_secs: nornir_identity_session::invites::INVITE_TTL_SECS,
            limits: Limits::default(),
        }
    }
}