Skip to main content

gunnar_server/
identity.rs

1//! **The identity core, built at the start** (IDENTITY-HOST, 2026-10-27):
2//! edda's `nornir_identity::Identity` over [`gunnar_grpc::identity::GunnarHost`].
3//! Its own records (sessions and invites as hashes, the floors, the sign-up
4//! state) live in `<data root>/identity.json`, written atomically and framed in
5//! the transaction log like every other document, so a twin holds them too.
6//! Mail: none (airgap) — an invite's link is handed over, and a sold estate's
7//! first-login link is the shop's to mail.
8
9use std::path::Path;
10use std::sync::Arc;
11
12use gunnar_grpc::identity::{
13    system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, Persist,
14};
15use gunnar_grpc::{ControlPlane, ServerConfig};
16use nornir_identity::{Config, Identity, NoMail, Site};
17
18/// The identity core's document under the data root.
19pub const FILE: &str = "identity.json";
20
21/// The stable code of [`read`]'s refusal.
22pub const UNREADABLE: &str = "identity-unreadable";
23
24/// **Read the tables** (RA5-9, fail closed): no document yet (`NotFound`) is
25/// empty tables, the first start. ANY other failure — a read error (EACCES,
26/// EIO, a restore with the wrong owner) or a document that does not parse — is
27/// refused by name and the file is left exactly as it is: started empty over
28/// it, a deactivated person's passkey would sign in again, every open invite
29/// and session record would be gone, and the first login would write over the
30/// unread file.
31pub fn read(root: &Path) -> Result<IdentityTables, String> {
32    let path = root.join(FILE);
33    let text = match std::fs::read_to_string(&path) {
34        Ok(text) => text,
35        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(IdentityTables::default()),
36        Err(e) => {
37            return Err(format!(
38                "[{UNREADABLE}] {} does not read ({e}): the identity core does not start on empty \
39                 tables over it. Give it back to this server's user (owner and mode), or restore \
40                 it, then start again; it was not touched",
41                path.display()
42            ))
43        }
44    };
45    GunnarStore::read_tables(&text).map_err(|why| {
46        format!(
47            "[{UNREADABLE}] {}: {why}. The identity core does not start on empty tables over it; \
48             restore it, then start again; it was not touched",
49            path.display()
50        )
51    })
52}
53
54/// **Follow the main** (RA5-10): on a twin, the primary's `identity.json` arrived
55/// (a frame, a snapshot) — the core's tables are read from it again, so the
56/// twin's identity state is the main's, not the one it loaded at its own boot.
57/// A document that does not read leaves the tables as they were and says so.
58pub fn follow(root: &Path, identity: &GunnarIdentity) -> Result<(), String> {
59    let tables = read(root)?;
60    identity.host().gunnar_store().reload(tables);
61    Ok(())
62}
63
64/// The core this server serves, or why it does not start ([`read`]).
65pub fn try_build(
66    root: &Path,
67    control: Arc<dyn ControlPlane>,
68    config: &ServerConfig,
69    root_site: bool,
70) -> Result<Arc<GunnarIdentity>, String> {
71    let tables = read(root)?;
72    let dir = root.to_path_buf();
73    let persist: Persist = Box::new(move |json: &str| {
74        crate::durable::write_atomic(&dir, FILE, json.as_bytes()).map_err(|e| format!("{e:#}"))
75    });
76    let clock = system_clock();
77    let store = GunnarStore::new(control, tables, Some(persist), Arc::clone(&clock));
78    let site = if root_site { Site::Root } else { Site::Private };
79    let host = GunnarHost::new(site, store, Arc::new(NoMail), clock);
80    let core = Config {
81        parties: config.web_relying_parties.clone(),
82        user_verification: config.web_user_verification,
83        ..Config::default()
84    };
85    Ok(Arc::new(Identity::new(host, core)))
86}
87
88/// [`try_build`] for tests and tools, which have no start to refuse: a
89/// document that does not read is a panic naming it. The server uses
90/// [`try_build`].
91pub fn build(
92    root: &Path,
93    control: Arc<dyn ControlPlane>,
94    config: &ServerConfig,
95    root_site: bool,
96) -> Arc<GunnarIdentity> {
97    try_build(root, control, config, root_site).unwrap_or_else(|why| panic!("{why}"))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// The control plane of a fresh box under `dir`.
105    fn plane(dir: &Path) -> (Arc<crate::stores::ServerStores>, Arc<dyn ControlPlane>) {
106        let stores = Arc::new(crate::stores::ServerStores::open(dir).unwrap());
107        let control: Arc<dyn ControlPlane> = Arc::new(crate::control_plane::CoreControlPlane::new(
108            Arc::clone(&stores),
109        ));
110        (stores, control)
111    }
112
113    /// ★ RA5-9: a document that does not parse is refused by name and left in
114    /// place: never moved aside into empty tables, never written over.
115    #[test]
116    fn a_document_that_does_not_parse_refuses_the_start_and_is_never_written_over() {
117        let dir = tempfile::tempdir().unwrap();
118        std::fs::write(dir.path().join(FILE), b"{not json").unwrap();
119        let why = read(dir.path()).expect_err("no empty tables over an unread document");
120        assert!(
121            why.contains("[identity-unreadable]") && why.contains("identity.json"),
122            "{why}"
123        );
124        let (_stores, control) = plane(dir.path());
125        let refused = try_build(dir.path(), control, &ServerConfig::default(), false)
126            .map(|_| ())
127            .expect_err("the core does not start");
128        assert!(refused.contains("[identity-unreadable]"), "{refused}");
129        assert_eq!(
130            std::fs::read(dir.path().join(FILE)).unwrap(),
131            b"{not json",
132            "the file is untouched"
133        );
134        let names: Vec<_> = std::fs::read_dir(dir.path())
135            .unwrap()
136            .flatten()
137            .map(|e| e.file_name().to_string_lossy().into_owned())
138            .collect();
139        assert!(
140            !names
141                .iter()
142                .any(|n| n.starts_with("identity.json.unreadable-")),
143            "nothing moved aside: {names:?}"
144        );
145    }
146
147    /// ★ RA5-9: a document this server's user cannot read (mode 0000: a restore
148    /// with the wrong owner, EACCES) is refused by name, never read as empty.
149    #[cfg(unix)]
150    #[test]
151    fn an_unreadable_document_refuses_the_start_by_name_and_is_never_written_over() {
152        use std::os::unix::fs::PermissionsExt as _;
153        let dir = tempfile::tempdir().unwrap();
154        let path = dir.path().join(FILE);
155        std::fs::write(&path, br#"{"people":{"ex-admin":{"state":"deactivated"}}}"#).unwrap();
156        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
157        if std::fs::read(&path).is_ok() {
158            // root reads a 0000 file; the refusal is then not reachable this way.
159            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
160            eprintln!("skipped: this user reads a mode-0000 file (root)");
161            return;
162        }
163        let why = read(dir.path()).expect_err("an unreadable document is no empty tables");
164        assert!(
165            why.contains("[identity-unreadable]") && why.contains("does not read"),
166            "{why}"
167        );
168        let (_stores, control) = plane(dir.path());
169        assert!(
170            try_build(dir.path(), control, &ServerConfig::default(), false).is_err(),
171            "the core does not start"
172        );
173        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
174        assert_eq!(
175            std::fs::read(&path).unwrap(),
176            br#"{"people":{"ex-admin":{"state":"deactivated"}}}"#,
177            "untouched"
178        );
179    }
180
181    #[test]
182    fn no_document_is_no_tables() {
183        let dir = tempfile::tempdir().unwrap();
184        assert_eq!(read(dir.path()).unwrap(), IdentityTables::default());
185    }
186
187    /// ★ RA5-10: on a twin (and a browse replica) the core takes no identity
188    /// write — refused by name, identity.json untouched — and the twin's tables
189    /// follow the main's document when it arrives.
190    #[test]
191    fn a_twin_refuses_identity_writes_by_name_and_follows_the_mains_tables() {
192        use nornir_identity::{Session, UserStore as _};
193        let dir = tempfile::tempdir().unwrap();
194        let (stores, control) = plane(dir.path());
195        let core = build(
196            dir.path(),
197            Arc::clone(&control),
198            &ServerConfig::default(),
199            false,
200        );
201        let store = core.host().gunnar_store();
202        let session = |id: &str| Session {
203            id: id.into(),
204            user: "alice".into(),
205            auth_time: 1,
206            expires: i64::MAX,
207            stepped_up_at: None,
208        };
209        store
210            .put_session(&session("on-the-main"))
211            .expect("a primary writes");
212
213        stores.mark_twin();
214        let before = std::fs::read(dir.path().join(FILE)).unwrap();
215        let refused = store
216            .put_session(&session("on-the-twin"))
217            .expect_err("a twin takes no identity write");
218        assert!(
219            refused.0.contains("[identity-read-only]") && refused.0.contains("twin"),
220            "{}",
221            refused.0
222        );
223        let refused = store
224            .update_invites(&mut |_| {})
225            .expect_err("no invite either");
226        assert!(refused.0.contains("[identity-read-only]"), "{}", refused.0);
227        assert!(store.delete_session("on-the-main").is_err());
228        assert_eq!(
229            std::fs::read(dir.path().join(FILE)).unwrap(),
230            before,
231            "identity.json untouched"
232        );
233
234        // The main's document arrives as a File frame: the twin applies it (it stores every
235        // other data frame) and the core answers from the main's tables.
236        stores.set_identity(&core);
237        let mut main = store.tables();
238        main.sessions.push(row("from-the-main"));
239        let json = serde_json::to_string(&main).unwrap();
240        let meta = gunnar_core::txlog::Meta::File {
241            path: FILE.into(),
242            deleted: false,
243        }
244        .to_bytes();
245        let took = crate::twin::take_raw(
246            &stores,
247            7,
248            gunnar_core::txlog::Kind::File as u16,
249            &meta,
250            json.as_bytes(),
251        )
252        .unwrap();
253        assert_eq!(took, crate::twin::Applied::Done);
254        assert!(
255            store.session("from-the-main").unwrap().is_some(),
256            "the twin follows the main"
257        );
258        assert_eq!(
259            std::fs::read_to_string(dir.path().join(FILE)).unwrap(),
260            json
261        );
262    }
263
264    fn row(id: &str) -> gunnar_grpc::identity::SessionRow {
265        gunnar_grpc::identity::SessionRow {
266            id: id.into(),
267            user: "alice".into(),
268            auth_time: 1,
269            expires: i64::MAX,
270            stepped_up_at: None,
271        }
272    }
273}