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};
pub const FILE: &str = "identity.json";
pub const UNREADABLE: &str = "identity-unreadable";
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()
)
})
}
pub fn follow(root: &Path, identity: &GunnarIdentity) -> Result<(), String> {
let tables = read(root)?;
identity.host().gunnar_store().reload(tables);
Ok(())
}
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)))
}
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::*;
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)
}
#[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:?}"
);
}
#[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() {
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());
}
#[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"
);
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,
}
}
}