1use 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
18pub const FILE: &str = "identity.json";
20
21pub const UNREADABLE: &str = "identity-unreadable";
23
24pub 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
54pub fn follow(root: &Path, identity: &GunnarIdentity) -> Result<(), String> {
59 let tables = read(root)?;
60 identity.host().gunnar_store().reload(tables);
61 Ok(())
62}
63
64pub 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
88pub 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 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 #[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 #[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 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 #[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 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}