crates/gunnar-server/src/identity.rs
//! **The identity core, built at the start** (IDENTITY-HOST, 2026-10-27):
//! edda's `nornir_identity::Identity` over [`gunnar_grpc::identity::GunnarHost`].
//! Its own records (sessions and invites as hashes, the floors, the sign-up
//! state) live in `<data root>/identity.json`, written atomically and framed in
//! the transaction log like every other document, so a twin holds them too.
//! Mail: none (airgap) — an invite's link is handed over, and a sold estate's
//! first-login link is the shop's to mail.
use std::path::Path;
use std::sync::Arc;
use gunnar_grpc::identity::{
system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, Persist,
};
use gunnar_grpc::{ControlPlane, ServerConfig};
use nornir_identity::{Config, Identity, NoMail, Site};
/// The identity core's document under the data root.
pub const FILE: &str = "identity.json";
/// The stable code of [`read`]'s refusal.
pub const UNREADABLE: &str = "identity-unreadable";
/// **Read the tables** (RA5-9, fail closed): no document yet (`NotFound`) is
/// empty tables, the first start. ANY other failure — a read error (EACCES,
/// EIO, a restore with the wrong owner) or a document that does not parse — is
/// refused by name and the file is left exactly as it is: started empty over
/// it, a deactivated person's passkey would sign in again, every open invite
/// and session record would be gone, and the first login would write over the
/// unread file.
pub fn read(root: &Path) -> Result<IdentityTables, String> {
let path = root.join(FILE);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(IdentityTables::default()),
Err(e) => {
return Err(format!(
"[{UNREADABLE}] {} does not read ({e}): the identity core does not start on empty \
tables over it. Give it back to this server's user (owner and mode), or restore \
it, then start again; it was not touched",
path.display()
))
}
};
GunnarStore::read_tables(&text).map_err(|why| {
format!(
"[{UNREADABLE}] {}: {why}. The identity core does not start on empty tables over it; \
restore it, then start again; it was not touched",
path.display()
)
})
}
/// **Follow the main** (RA5-10): on a twin, the primary's `identity.json` arrived
/// (a frame, a snapshot) — the core's tables are read from it again, so the
/// twin's identity state is the main's, not the one it loaded at its own boot.
/// A document that does not read leaves the tables as they were and says so.
pub fn follow(root: &Path, identity: &GunnarIdentity) -> Result<(), String> {
let tables = read(root)?;
identity.host().gunnar_store().reload(tables);
Ok(())
}
/// The core this server serves, or why it does not start ([`read`]).
pub fn try_build(
root: &Path,
control: Arc<dyn ControlPlane>,
config: &ServerConfig,
root_site: bool,
) -> Result<Arc<GunnarIdentity>, String> {
let tables = read(root)?;
let dir = root.to_path_buf();
let persist: Persist = Box::new(move |json: &str| {
crate::durable::write_atomic(&dir, FILE, json.as_bytes()).map_err(|e| format!("{e:#}"))
});
let clock = system_clock();
let store = GunnarStore::new(control, tables, Some(persist), Arc::clone(&clock));
let site = if root_site { Site::Root } else { Site::Private };
let host = GunnarHost::new(site, store, Arc::new(NoMail), clock);
let core = Config {
parties: config.web_relying_parties.clone(),
user_verification: config.web_user_verification,
..Config::default()
};
Ok(Arc::new(Identity::new(host, core)))
}
/// [`try_build`] for tests and tools, which have no start to refuse: a
/// document that does not read is a panic naming it. The server uses
/// [`try_build`].
pub fn build(
root: &Path,
control: Arc<dyn ControlPlane>,
config: &ServerConfig,
root_site: bool,
) -> Arc<GunnarIdentity> {
try_build(root, control, config, root_site).unwrap_or_else(|why| panic!("{why}"))
}
#[cfg(test)]
mod tests {
use super::*;
/// The control plane of a fresh box under `dir`.
fn plane(dir: &Path) -> (Arc<crate::stores::ServerStores>, Arc<dyn ControlPlane>) {
let stores = Arc::new(crate::stores::ServerStores::open(dir).unwrap());
let control: Arc<dyn ControlPlane> = Arc::new(crate::control_plane::CoreControlPlane::new(
Arc::clone(&stores),
));
(stores, control)
}
/// ★ RA5-9: a document that does not parse is refused by name and left in
/// place: never moved aside into empty tables, never written over.
#[test]
fn a_document_that_does_not_parse_refuses_the_start_and_is_never_written_over() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(FILE), b"{not json").unwrap();
let why = read(dir.path()).expect_err("no empty tables over an unread document");
assert!(
why.contains("[identity-unreadable]") && why.contains("identity.json"),
"{why}"
);
let (_stores, control) = plane(dir.path());
let refused = try_build(dir.path(), control, &ServerConfig::default(), false)
.map(|_| ())
.expect_err("the core does not start");
assert!(refused.contains("[identity-unreadable]"), "{refused}");
assert_eq!(
std::fs::read(dir.path().join(FILE)).unwrap(),
b"{not json",
"the file is untouched"
);
let names: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
!names
.iter()
.any(|n| n.starts_with("identity.json.unreadable-")),
"nothing moved aside: {names:?}"
);
}
/// ★ RA5-9: a document this server's user cannot read (mode 0000: a restore
/// with the wrong owner, EACCES) is refused by name, never read as empty.
#[cfg(unix)]
#[test]
fn an_unreadable_document_refuses_the_start_by_name_and_is_never_written_over() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(FILE);
std::fs::write(&path, br#"{"people":{"ex-admin":{"state":"deactivated"}}}"#).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&path).is_ok() {
// root reads a 0000 file; the refusal is then not reachable this way.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
eprintln!("skipped: this user reads a mode-0000 file (root)");
return;
}
let why = read(dir.path()).expect_err("an unreadable document is no empty tables");
assert!(
why.contains("[identity-unreadable]") && why.contains("does not read"),
"{why}"
);
let (_stores, control) = plane(dir.path());
assert!(
try_build(dir.path(), control, &ServerConfig::default(), false).is_err(),
"the core does not start"
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(
std::fs::read(&path).unwrap(),
br#"{"people":{"ex-admin":{"state":"deactivated"}}}"#,
"untouched"
);
}
#[test]
fn no_document_is_no_tables() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read(dir.path()).unwrap(), IdentityTables::default());
}
/// ★ RA5-10: on a twin (and a browse replica) the core takes no identity
/// write — refused by name, identity.json untouched — and the twin's tables
/// follow the main's document when it arrives.
#[test]
fn a_twin_refuses_identity_writes_by_name_and_follows_the_mains_tables() {
use nornir_identity::{Session, UserStore as _};
let dir = tempfile::tempdir().unwrap();
let (stores, control) = plane(dir.path());
let core = build(
dir.path(),
Arc::clone(&control),
&ServerConfig::default(),
false,
);
let store = core.host().gunnar_store();
let session = |id: &str| Session {
id: id.into(),
user: "alice".into(),
auth_time: 1,
expires: i64::MAX,
stepped_up_at: None,
};
store
.put_session(&session("on-the-main"))
.expect("a primary writes");
stores.mark_twin();
let before = std::fs::read(dir.path().join(FILE)).unwrap();
let refused = store
.put_session(&session("on-the-twin"))
.expect_err("a twin takes no identity write");
assert!(
refused.0.contains("[identity-read-only]") && refused.0.contains("twin"),
"{}",
refused.0
);
let refused = store
.update_invites(&mut |_| {})
.expect_err("no invite either");
assert!(refused.0.contains("[identity-read-only]"), "{}", refused.0);
assert!(store.delete_session("on-the-main").is_err());
assert_eq!(
std::fs::read(dir.path().join(FILE)).unwrap(),
before,
"identity.json untouched"
);
// The main's document arrives as a File frame: the twin applies it (it stores every
// other data frame) and the core answers from the main's tables.
stores.set_identity(&core);
let mut main = store.tables();
main.sessions.push(row("from-the-main"));
let json = serde_json::to_string(&main).unwrap();
let meta = gunnar_core::txlog::Meta::File {
path: FILE.into(),
deleted: false,
}
.to_bytes();
let took = crate::twin::take_raw(
&stores,
7,
gunnar_core::txlog::Kind::File as u16,
&meta,
json.as_bytes(),
)
.unwrap();
assert_eq!(took, crate::twin::Applied::Done);
assert!(
store.session("from-the-main").unwrap().is_some(),
"the twin follows the main"
);
assert_eq!(
std::fs::read_to_string(dir.path().join(FILE)).unwrap(),
json
);
}
fn row(id: &str) -> gunnar_grpc::identity::SessionRow {
gunnar_grpc::identity::SessionRow {
id: id.into(),
user: "alice".into(),
auth_time: 1,
expires: i64::MAX,
stepped_up_at: None,
}
}
}