use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use gunnar_proto::{GrantLevel, PrincipalKind};
use nornir_identity::{
Credential, Event, IdentityHost, Invite, Mailer, Revocations, Roles, Session, Site, StoreError,
User, UserState, UserStore,
};
use serde::{Deserialize, Serialize};
use crate::auth::Access;
use crate::seam::ControlPlane;
pub const SERVER_ADMIN: &str = "server-admin";
pub const SERVER_WRITE: &str = "server-write";
pub const PASSKEY_LABEL: &str = "passkey";
pub const READ_ONLY: &str = "identity-read-only";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionRow {
pub id: String,
pub user: String,
pub auth_time: i64,
pub expires: i64,
#[serde(default)]
pub stepped_up_at: Option<i64>,
}
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(s: &SessionRow) -> Self {
Session {
id: s.id.clone(),
user: s.user.clone(),
auth_time: s.auth_time,
expires: s.expires,
stepped_up_at: s.stepped_up_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersonRow {
#[serde(default)]
pub email: Option<String>,
pub state: String,
}
fn state_name(s: UserState) -> &'static str {
match s {
UserState::Pending => "pending",
UserState::Active => "active",
UserState::Deactivated => "deactivated",
}
}
fn state_of(name: &str) -> UserState {
match name {
"pending" => UserState::Pending,
"deactivated" => UserState::Deactivated,
_ => UserState::Active,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentityTables {
#[serde(default)]
pub sessions: Vec<SessionRow>,
#[serde(default)]
pub invites: Vec<Invite>,
#[serde(default)]
pub revocations: Revocations,
#[serde(default)]
pub people: BTreeMap<String, PersonRow>,
#[serde(default)]
pub sso_admitted: Vec<String>,
#[serde(default)]
pub sso_admitted_by_fingerprint: BTreeMap<String, String>,
}
pub type Persist = Box<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
pub type Clock = Arc<dyn Fn() -> i64 + Send + Sync>;
pub fn system_clock() -> Clock {
Arc::new(crate::bus::now_unix_ms)
}
fn store_err(e: impl std::fmt::Display) -> StoreError {
StoreError(e.to_string())
}
pub struct GunnarStore {
plane: Arc<dyn ControlPlane>,
tables: Mutex<IdentityTables>,
persist: Option<Persist>,
clock: Clock,
}
impl std::fmt::Debug for GunnarStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GunnarStore").finish_non_exhaustive()
}
}
impl GunnarStore {
pub fn new(
plane: Arc<dyn ControlPlane>,
tables: IdentityTables,
persist: Option<Persist>,
clock: Clock,
) -> Self {
GunnarStore {
plane,
tables: Mutex::new(tables),
persist,
clock,
}
}
pub fn read_tables(json: &str) -> Result<IdentityTables, String> {
if json.trim().is_empty() {
return Ok(IdentityTables::default());
}
serde_json::from_str(json).map_err(|e| format!("the identity tables do not read: {e}"))
}
fn lock(&self) -> std::sync::MutexGuard<'_, IdentityTables> {
self.tables.lock().unwrap_or_else(|p| p.into_inner())
}
fn writable(&self) -> Result<(), StoreError> {
match self.plane.frozen() {
None => Ok(()),
Some(why) => Err(StoreError(format!(
"[{READ_ONLY}] {why}: it takes no identity write (no login, redeem, passkey, invite or \
logout here); its identity records follow the primary's. Do it at the primary"
))),
}
}
pub fn reload(&self, tables: IdentityTables) {
*self.lock() = tables;
}
fn change<R>(&self, f: impl FnOnce(&mut IdentityTables) -> R) -> Result<R, StoreError> {
self.writable()?;
let mut tables = self.lock();
let mut next = tables.clone();
let r = f(&mut next);
if let Some(persist) = &self.persist {
let json = serde_json::to_string(&next).map_err(store_err)?;
persist(&json).map_err(StoreError)?;
}
*tables = next;
Ok(r)
}
pub fn tables(&self) -> IdentityTables {
self.lock().clone()
}
pub fn admit_sso_session(&self, token: &str) -> Result<(), StoreError> {
let id = nornir_identity_session::invites::hash_secret(token);
let fingerprint = crate::auth::session_fingerprint(token);
self.change(|t| {
let live: std::collections::BTreeSet<String> =
t.sessions.iter().map(|s| s.id.clone()).collect();
let mut kept: Vec<String> = t
.sso_admitted
.iter()
.filter(|s| live.contains(s.as_str()) && **s != id)
.cloned()
.collect();
kept.push(id.clone());
t.sso_admitted = kept;
t.sso_admitted_by_fingerprint
.retain(|fp, sid| live.contains(sid.as_str()) && *fp != fingerprint);
t.sso_admitted_by_fingerprint
.insert(fingerprint.clone(), id.clone());
})
}
pub fn sso_admitted_fingerprint(&self, fingerprint: &str) -> bool {
let now = (self.clock)().div_euclid(1000);
let t = self.lock();
t.sso_admitted_by_fingerprint
.get(fingerprint)
.is_some_and(|id| {
t.sso_admitted.contains(id)
&& t.sessions.iter().any(|s| s.id == *id && s.expires > now)
})
}
pub fn stepped_up_at(&self, token: &str) -> Option<i64> {
let id = nornir_identity_session::invites::hash_secret(token);
self.lock()
.sessions
.iter()
.find(|s| s.id == id)
.and_then(|s| s.stepped_up_at)
}
pub fn sso_admitted(&self, token: &str) -> bool {
let id = nornir_identity_session::invites::hash_secret(token);
self.lock().sso_admitted.contains(&id)
}
fn groups_of(&self, id: &str) -> Result<Vec<String>, StoreError> {
if self
.plane
.may(id, &Access::Server, GrantLevel::Admin)
.map_err(store_err)?
{
return Ok(vec![SERVER_ADMIN.to_owned()]);
}
if self
.plane
.may(id, &Access::Server, GrantLevel::Write)
.map_err(store_err)?
{
return Ok(vec![SERVER_WRITE.to_owned()]);
}
Ok(Vec::new())
}
fn directory_active(&self, id: &str) -> Result<Option<bool>, StoreError> {
let Some(plane) = self.plane.sso() else {
return Ok(None);
};
let Some(account) = self.plane.employer_of(id).map_err(store_err)? else {
return Ok(None);
};
Ok(plane
.directory(&account)
.map_err(store_err)?
.iter()
.find(|row| row.user.principal_id == id)
.map(|row| !row.deleted && row.user.active))
}
fn person(&self, p: gunnar_proto::Principal) -> Result<User, StoreError> {
let row = self.lock().people.get(&p.id).cloned();
let directory = self.directory_active(&p.id)?;
let state = match &row {
_ if directory == Some(false) => UserState::Deactivated,
Some(row) if directory == Some(true) && state_of(&row.state) == UserState::Pending => {
UserState::Active
}
Some(row) => state_of(&row.state),
None if directory == Some(true) => UserState::Active,
None if !self
.plane
.credentials(Some(&p.id))
.map_err(store_err)?
.is_empty() =>
{
UserState::Active
}
None => UserState::Pending,
};
Ok(User {
groups: self.groups_of(&p.id)?,
email: row.and_then(|r| r.email),
display: p.name,
state,
created: p.created_at_unix_ms.div_euclid(1000),
id: p.id,
})
}
fn first_invite(&self) -> Result<Option<Invite>, StoreError> {
Ok(self
.plane
.first_invite()
.map_err(store_err)?
.map(|f| Invite {
id: nornir_identity::fed_invite_id(&f.secret_hash),
display: String::new(),
groups: Vec::new(),
created: f.created_at_unix_ms.div_euclid(1000),
expires: f.expires_at_unix_ms.div_euclid(1000),
created_by: format!(
"{}{}:installer",
nornir_identity::PERSON_INVITE,
f.principal_id
),
used_by: None,
used_at: None,
revoked: false,
}))
}
fn credential_out(c: crate::seam::StoredCredential) -> Credential {
Credential {
id: c.credential.credential_id,
user: c.credential.principal_id,
public_key: c.public_key,
sign_count: c.credential.sign_count,
created: c.credential.created_at_unix_ms.div_euclid(1000),
}
}
}
fn is_person(p: &gunnar_proto::Principal) -> bool {
p.kind == PrincipalKind::User as i32
}
fn is_machine(p: &gunnar_proto::Principal) -> bool {
p.kind == PrincipalKind::Machine as i32
}
impl UserStore for GunnarStore {
fn user(&self, id: &str) -> Result<Option<User>, StoreError> {
match self.plane.principal(id).map_err(store_err)? {
Some(p) if is_person(&p) || is_machine(&p) => self.person(p).map(Some),
_ => Ok(None),
}
}
fn user_by_email(&self, email: &str) -> Result<Option<User>, StoreError> {
let id = self
.lock()
.people
.iter()
.find(|(_, r)| r.email.as_deref() == Some(email))
.map(|(id, _)| id.clone());
match id {
Some(id) => self.user(&id),
None => Ok(None),
}
}
fn users(&self) -> Result<Vec<User>, StoreError> {
let all = self.plane.list_principals().map_err(store_err)?;
all.into_iter()
.filter(is_person)
.map(|p| self.person(p))
.collect()
}
fn put_user(&self, user: &User) -> Result<(), StoreError> {
self.writable()?;
match self.plane.principal(&user.id).map_err(store_err)? {
None => {
let made = self
.plane
.add_principal(&user.id, &user.display, PrincipalKind::User);
if let Err(crate::seam::ControlError::AlreadyExists(_)) = made {
let suffix: String = user
.id
.chars()
.rev()
.take(6)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let name = format!("{}-{suffix}", user.display);
self.plane
.add_principal(&user.id, &name, PrincipalKind::User)
.map_err(store_err)?;
} else {
made.map_err(store_err)?;
}
}
Some(p) if !is_person(&p) => {
return Err(StoreError(format!("principal {} is not a person", user.id)));
}
Some(p) if p.name != user.display => {
self.plane
.rename_principal(&user.id, &user.display)
.map_err(store_err)?;
}
Some(_) => {}
}
let want = if user.groups.iter().any(|g| g == SERVER_ADMIN) {
Some(GrantLevel::Admin)
} else if user.groups.iter().any(|g| g == SERVER_WRITE) {
Some(GrantLevel::Write)
} else {
None
};
if let Some(level) = want {
if !self
.plane
.may(&user.id, &Access::Server, level)
.map_err(store_err)?
{
self.plane
.set_server_level(&user.id, level)
.map_err(store_err)?;
}
}
let row = PersonRow {
email: user.email.clone(),
state: state_name(user.state).to_owned(),
};
self.change(|t| {
t.people.insert(user.id.clone(), row);
})
}
fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError> {
Ok(self
.plane
.credential(id)
.map_err(store_err)?
.map(Self::credential_out))
}
fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError> {
let rows = self.plane.credentials(Some(user)).map_err(store_err)?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if let Some(c) = self
.plane
.credential(&row.credential_id)
.map_err(store_err)?
{
out.push(Self::credential_out(c));
}
}
Ok(out)
}
fn put_credential(&self, c: &Credential) -> Result<(), StoreError> {
self.writable()?;
match self.plane.credential(&c.id).map_err(store_err)? {
None => {
self.plane
.add_credential(&c.user, &c.id, &c.public_key, PASSKEY_LABEL, c.sign_count)
.map_err(store_err)?;
}
Some(_) => {
self.plane
.record_passkey_use(&c.id, c.sign_count, (self.clock)())
.map_err(store_err)?;
}
}
Ok(())
}
fn session(&self, id: &str) -> Result<Option<Session>, StoreError> {
Ok(self
.lock()
.sessions
.iter()
.find(|s| s.id == id)
.map(Session::from))
}
fn put_session(&self, session: &Session) -> Result<(), StoreError> {
let now = (self.clock)().div_euclid(1000);
self.change(|t| {
t.sessions.retain(|s| s.id != session.id && s.expires > now);
t.sessions.push(SessionRow::from(session));
})
}
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> {
let first = self.first_invite()?;
let mut all = self.lock().invites.clone();
all.extend(first);
Ok(all)
}
fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError> {
let first = self.first_invite()?;
let now = (self.clock)().div_euclid(1000);
let mut dropped = false;
self.change(|t| {
let mut all = t.invites.clone();
all.extend(first.clone());
change(&mut all);
if let Some(first) = &first {
match all.iter().position(|i| i.id == first.id) {
Some(at) => {
let fed = all.remove(at);
dropped = !fed.open(now);
}
None => dropped = true,
}
}
t.invites = all;
})?;
if dropped {
self.plane.drop_first_invite().map_err(store_err)?;
}
Ok(())
}
fn revocations(&self) -> Result<Revocations, StoreError> {
Ok(self.lock().revocations.clone())
}
fn update_revocations(
&self,
change: &mut dyn FnMut(&mut Revocations),
) -> Result<(), StoreError> {
self.change(|t| change(&mut t.revocations))
}
}
pub type AuditSink = Arc<dyn Fn(&Event) + Send + Sync>;
pub struct GunnarHost {
site: Site,
store: GunnarStore,
mailer: Arc<dyn Mailer>,
sink: Option<AuditSink>,
clock: Clock,
}
impl std::fmt::Debug for GunnarHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GunnarHost")
.field("site", &self.site)
.finish_non_exhaustive()
}
}
impl GunnarHost {
pub fn new(site: Site, store: GunnarStore, mailer: Arc<dyn Mailer>, clock: Clock) -> Self {
GunnarHost {
site,
store,
mailer,
sink: None,
clock,
}
}
pub fn with_audit_sink(mut self, sink: AuditSink) -> Self {
self.sink = Some(sink);
self
}
pub fn gunnar_store(&self) -> &GunnarStore {
&self.store
}
fn account_audit(&self, event: &Event) {
let refused = matches!(event, Event::LoginRefused { .. });
let (actor, kind, target, detail) = match event {
Event::LoggedIn { .. } if VOUCHING.with(std::cell::Cell::get) => return,
Event::LoggedIn {
user,
user_verified,
..
} => (
user,
"login",
"passkey",
if *user_verified {
"passkey, user-verified"
} else {
"passkey"
}
.to_owned(),
),
Event::InviteMinted { invite, by, .. } => {
(by, "invite.created", "invite", format!("invite {invite}"))
}
Event::InviteRevoked { invite, by } => {
(by, "invite.revoked", "invite", format!("invite {invite}"))
}
Event::LoginRefused {
code,
user: Some(user),
} => (
user,
"login",
"passkey",
format!("passkey: refused ({code:?})"),
),
_ => return,
};
let Some(plane) = self.store.plane.enterprise() else {
return;
};
let Ok(Some(account)) = plane.account_of_principal(actor) else {
return;
};
let record = crate::enterprise::AccountAuditEvent {
kind: kind.to_owned(),
actor: actor.clone(),
actor_machine: false,
target: target.to_owned(),
detail,
refused,
};
if let Err(err) = plane.account_audit_record(&account, &record) {
tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "account_audit.not_written", account = %account, kind, reason = %err, "an identity event was not written to its account's audit log");
}
}
}
impl IdentityHost for GunnarHost {
fn site(&self) -> Site {
self.site
}
fn store(&self) -> &dyn UserStore {
&self.store
}
fn roles(&self, user: &User) -> Roles {
let admin = user.groups.iter().any(|g| g == SERVER_ADMIN);
Roles {
admin,
names: user.groups.clone(),
}
}
fn audit(&self, event: Event) {
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "identity", detail = ?event, "identity");
self.account_audit(&event);
if let Some(sink) = &self.sink {
sink(&event);
}
}
fn mailer(&self) -> &dyn Mailer {
&*self.mailer
}
fn may_enrol(&self, by: &User, person: &User) -> bool {
let plane = &self.store.plane;
match plane.employer_of(&person.id) {
Ok(Some(account)) => plane
.may(&by.id, &Access::Namespace(account), GrantLevel::Admin)
.unwrap_or(false),
_ => false,
}
}
fn now_ms(&self) -> i64 {
(self.clock)()
}
}
pub type GunnarIdentity = nornir_identity::Identity<GunnarHost>;
thread_local! {
static VOUCHING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub fn vouched_session(
identity: &GunnarIdentity,
user_id: &str,
user_verified: bool,
) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
VOUCHING.with(|v| v.set(true));
let made = identity.vouched_session(user_id, user_verified);
VOUCHING.with(|v| v.set(false));
made
}
pub fn status_of(refused: nornir_identity::Refused) -> tonic::Status {
nornir_identity::grpc::status(refused)
}