1use std::collections::{BTreeMap, HashMap};
5use std::sync::{Arc, Mutex};
6
7use anyhow::{bail, Context, Result};
8use chacha20poly1305::aead::{Aead, KeyInit, Payload};
9use chacha20poly1305::{XChaCha20Poly1305, XNonce};
10use chrono::{Duration, Utc};
11use lancedb::arrow::arrow_array::{RecordBatch, RecordBatchIterator, StringArray};
12use lancedb::arrow::arrow_schema::{DataType, Field, Schema};
13use serde::{Deserialize, Serialize};
14
15use crate::backends::{
16 AccountStore, AuditLog, Authenticator, DeviceSignIn, Mailer, SecretStore, SettingsStore,
17};
18
19pub fn env_secret(name: &str) -> Option<String> {
21 if let Some(v) = std::env::var(name).ok().filter(|v| !v.trim().is_empty()) {
22 return Some(v.trim().to_string());
23 }
24 let path = std::env::var(format!("{name}_FILE")).ok()?;
25 match std::fs::read_to_string(&path) {
26 Ok(v) => Some(v.trim().to_string()).filter(|v| !v.is_empty()),
27 Err(e) => {
28 crate::ui::warn(&format!("{name}_FILE ({path}) cannot be read: {e}"));
29 None
30 }
31 }
32}
33
34#[derive(Clone)]
36pub struct ServerConfig {
37 pub public_url: String,
39 pub admin_emails: Vec<String>,
41 pub sign_in: Vec<SignIn>,
43 pub admin_token: Option<String>,
44 pub gateway_secret: Option<String>,
46 pub gateway_email_header: String,
48 pub gateway_url: Option<String>,
50 pub secret_key: String,
51 pub secret_key_previous: Option<String>,
52 pub smtp_url: Option<String>,
53 pub smtp_from: String,
54 pub trust_proxy: bool,
56 pub llm_key: Option<String>,
58 pub oidc: Option<OidcConfig>,
59}
60
61#[derive(Clone)]
63pub struct OidcConfig {
64 pub issuer: String,
65 pub client_id: String,
66 pub client_secret: String,
67 pub label: String,
69 pub allowed_domains: Vec<String>,
71}
72
73impl OidcConfig {
74 fn from_env() -> Result<Option<OidcConfig>> {
75 let Some(issuer) = env_secret("COMINDLY_OIDC_ISSUER") else {
76 return Ok(None);
77 };
78 Ok(Some(OidcConfig {
79 issuer: issuer.trim_end_matches('/').to_string(),
80 client_id: env_secret("COMINDLY_OIDC_CLIENT_ID")
81 .context("COMINDLY_OIDC_ISSUER needs COMINDLY_OIDC_CLIENT_ID")?,
82 client_secret: env_secret("COMINDLY_OIDC_CLIENT_SECRET")
83 .context("COMINDLY_OIDC_ISSUER needs COMINDLY_OIDC_CLIENT_SECRET")?,
84 label: env_secret("COMINDLY_OIDC_LABEL")
85 .unwrap_or_else(|| "your company account".into()),
86 allowed_domains: env_secret("COMINDLY_OIDC_ALLOWED_DOMAINS")
87 .unwrap_or_default()
88 .split([',', ' ', '\n'])
89 .map(|d| normalize(d).trim_start_matches('@').to_string())
90 .filter(|d| !d.is_empty())
91 .collect(),
92 }))
93 }
94}
95
96impl ServerConfig {
97 pub fn from_env() -> Result<Option<ServerConfig>> {
99 let Some(url) = env_secret("COMINDLY_PUBLIC_URL") else {
100 return Ok(None);
101 };
102 let admin_emails: Vec<String> = env_secret("COMINDLY_ADMIN_EMAILS")
103 .unwrap_or_default()
104 .split([',', ' ', '\n'])
105 .map(normalize)
106 .filter(|e| e.contains('@'))
107 .collect();
108 anyhow::ensure!(
109 !admin_emails.is_empty(),
110 "server mode needs COMINDLY_ADMIN_EMAILS: who administers this server"
111 );
112 let admin_token = env_secret("COMINDLY_ADMIN_TOKEN");
113 if let Some(t) = &admin_token {
114 anyhow::ensure!(
115 t.len() >= 32,
116 "COMINDLY_ADMIN_TOKEN is too short: use at least 32 random characters (e.g. `openssl rand -base64 32`)"
117 );
118 }
119 let gateway_secret = env_secret("COMINDLY_GATEWAY_SECRET");
120 if let Some(t) = &gateway_secret {
121 anyhow::ensure!(
122 t.len() >= 32,
123 "COMINDLY_GATEWAY_SECRET is too short: use at least 32 random characters (e.g. `openssl rand -base64 32`)"
124 );
125 }
126 let smtp_url = env_secret("COMINDLY_SMTP_URL");
127 let oidc = OidcConfig::from_env()?;
128 let sign_in = sign_in_methods(
129 env_secret("COMINDLY_SIGN_IN").as_deref(),
130 smtp_url.is_some(),
131 oidc.is_some(),
132 )?;
133 Ok(Some(ServerConfig {
134 public_url: url.trim_end_matches('/').to_string(),
135 admin_emails,
136 sign_in,
137 admin_token,
138 gateway_secret,
139 gateway_email_header: env_secret("COMINDLY_GATEWAY_EMAIL_HEADER")
140 .unwrap_or_else(|| "x-forwarded-email".into())
141 .to_lowercase(),
142 gateway_url: env_secret("COMINDLY_GATEWAY_URL"),
143 secret_key: env_secret("COMINDLY_SECRET_KEY")
144 .context("server mode needs COMINDLY_SECRET_KEY")?,
145 secret_key_previous: env_secret("COMINDLY_SECRET_KEY_PREVIOUS"),
146 smtp_url,
147 smtp_from: env_secret("COMINDLY_SMTP_FROM")
148 .unwrap_or_else(|| "comindly <comindly@localhost>".into()),
149 trust_proxy: env_secret("COMINDLY_TRUST_PROXY")
150 .is_some_and(|v| v == "true" || v == "1"),
151 llm_key: env_secret("OPENAI_API_KEY"),
152 oidc,
153 }))
154 }
155
156 pub fn https(&self) -> bool {
157 self.public_url.starts_with("https://")
158 }
159
160 pub fn allows(&self, method: SignIn) -> bool {
161 self.sign_in.contains(&method)
162 }
163
164 pub fn main_sign_in(&self) -> SignIn {
166 [SignIn::Email, SignIn::Code]
167 .into_iter()
168 .find(|m| self.allows(*m))
169 .unwrap_or(SignIn::Oidc)
170 }
171}
172
173fn sign_in_methods(set: Option<&str>, smtp: bool, oidc: bool) -> Result<Vec<SignIn>> {
176 let chosen: Vec<SignIn> = match set {
177 Some(v) => v
178 .split([',', ' '])
179 .filter(|m| !m.is_empty())
180 .map(|m| match m {
181 "email" => Ok(SignIn::Email),
182 "code" => Ok(SignIn::Code),
183 "oidc" => Ok(SignIn::Oidc),
184 other => bail!("COMINDLY_SIGN_IN lists `oidc`, `email` and `code`, not `{other}`"),
185 })
186 .collect::<Result<_>>()?,
187 None if oidc || smtp => [(oidc, SignIn::Oidc), (smtp, SignIn::Email)]
188 .into_iter()
189 .filter_map(|(on, m)| on.then_some(m))
190 .collect(),
191 None => vec![SignIn::Code],
192 };
193 anyhow::ensure!(
194 !chosen.is_empty(),
195 "COMINDLY_SIGN_IN names no way to sign in"
196 );
197 anyhow::ensure!(
198 smtp || !chosen.contains(&SignIn::Email),
199 "email sign-in needs COMINDLY_SMTP_URL"
200 );
201 anyhow::ensure!(
202 oidc || !chosen.contains(&SignIn::Oidc),
203 "oidc sign-in needs COMINDLY_OIDC_ISSUER"
204 );
205 Ok([SignIn::Oidc, SignIn::Email, SignIn::Code]
206 .into_iter()
207 .filter(|m| chosen.contains(m))
208 .collect())
209}
210
211pub struct Server {
213 pub config: ServerConfig,
214 pub accounts: Arc<dyn AccountStore>,
215 pub audit: Arc<dyn AuditLog>,
216 pub auth: Arc<dyn Authenticator>,
217 pub devices: Arc<dyn DeviceSignIn>,
218 pub secrets: Arc<dyn SecretStore>,
219 pub settings: Arc<dyn SettingsStore>,
220 pub mailer: Arc<dyn Mailer>,
221 pub jobs: Arc<dyn crate::jobs::JobStore>,
222 pub oidc: Option<Arc<crate::oidc::Oidc>>,
224}
225
226impl Server {
227 pub fn open(index_root: &str, config: ServerConfig) -> Result<Server> {
229 let store = Arc::new(Store::open(index_root, &config)?);
230 let mailer = Arc::new(crate::backends::Smtp {
231 url: config.smtp_url.clone(),
232 from: config.smtp_from.clone(),
233 });
234 let oidc = match &config.oidc {
235 Some(c) if config.allows(SignIn::Oidc) => Some(Arc::new(crate::oidc::Oidc::new(
236 c.clone(),
237 &config.public_url,
238 )?)),
239 _ => None,
240 };
241 Ok(Server {
242 config,
243 accounts: store.clone(),
244 audit: store.clone(),
245 auth: store.clone(),
246 devices: store.clone(),
247 secrets: store.clone(),
248 settings: store.clone(),
249 jobs: store,
250 mailer,
251 oidc,
252 })
253 }
254
255 pub fn git_token(&self, host: &str) -> Option<String> {
257 self.secrets
258 .secret(&git_secret(host))
259 .or_else(|| crate::config::env_token(host))
260 }
261}
262
263pub use comindly_api::server::{
264 AccessToken, AuditEntry, AuditPage, AuditQuery, DevicePoll, DeviceRequest, Invite, Member,
265 Role, SecretInfo, SignIn, Via,
266};
267
268pub fn normalize(email: &str) -> String {
269 email.trim().to_lowercase()
270}
271
272#[derive(Debug, Clone, Serialize)]
274pub struct Who {
275 pub email: String,
276 pub role: Role,
277}
278
279const SESSION_DAYS: i64 = 30;
280const LINK_MINUTES: i64 = 15;
281const INVITE_DAYS: i64 = 7;
282const MAX_FAILED: i64 = 5;
283const MAX_REDEEM_FAILED: i64 = 20;
284const LOCK_MINUTES: i64 = 15;
285const MAX_LINKS_PER_HOUR: i64 = 5;
286const DEVICE_MINUTES: i64 = 10;
287const MAX_DEVICE_STARTS: i64 = 10;
289pub use comindly_api::server::TOKEN_DAYS;
290const EXPIRED_TOKEN_DAYS: i64 = 30;
292const AUDIT_PAGE: usize = 50;
293const AUDIT_PAGE_MAX: usize = 500;
294const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
295
296fn now() -> String {
297 Utc::now().to_rfc3339()
298}
299
300pub fn new_token(prefix: &str) -> String {
302 let mut b = [0u8; 32];
303 getrandom::fill(&mut b).expect("system randomness");
304 format!("{prefix}{}", blake3::Hash::from(b).to_hex())
305}
306
307pub fn new_code() -> String {
309 let mut b = [0u8; 20];
310 getrandom::fill(&mut b).expect("system randomness");
311 let chars: Vec<char> = b
312 .iter()
313 .map(|x| CODE_ALPHABET[(*x as usize) % CODE_ALPHABET.len()] as char)
314 .collect();
315 chars
316 .chunks(4)
317 .map(|c| c.iter().collect::<String>())
318 .collect::<Vec<_>>()
319 .join("-")
320}
321
322fn code_hash(code: &str) -> String {
324 let plain: String = code
325 .chars()
326 .filter(char::is_ascii_alphanumeric)
327 .map(|c| c.to_ascii_uppercase())
328 .collect();
329 hash(&format!("code:{plain}"))
330}
331
332fn hash(token: &str) -> String {
333 blake3::hash(token.as_bytes()).to_hex().to_string()
334}
335
336fn cipher(key: &str) -> XChaCha20Poly1305 {
337 let k = blake3::derive_key("comindly secrets v1", key.as_bytes());
338 XChaCha20Poly1305::new((&k).into())
339}
340
341fn seal(key: &str, name: &str, value: &str) -> Result<(Vec<u8>, Vec<u8>)> {
343 let mut nonce = [0u8; 24];
344 getrandom::fill(&mut nonce).context("randomness")?;
345 let payload = Payload {
346 msg: value.as_bytes(),
347 aad: name.as_bytes(),
348 };
349 let sealed = cipher(key)
350 .encrypt(&XNonce::from(nonce), payload)
351 .map_err(|_| anyhow::anyhow!("encryption failed"))?;
352 Ok((sealed, nonce.to_vec()))
353}
354
355fn open(key: &str, name: &str, sealed: &[u8], nonce: &[u8]) -> Option<String> {
356 let payload = Payload {
357 msg: sealed,
358 aad: name.as_bytes(),
359 };
360 let plain = cipher(key)
361 .decrypt(&XNonce::try_from(nonce).ok()?, payload)
362 .ok()?;
363 String::from_utf8(plain).ok()
364}
365
366fn hint(value: &str) -> String {
368 let tail: String = value
369 .chars()
370 .rev()
371 .take(4)
372 .collect::<Vec<_>>()
373 .into_iter()
374 .rev()
375 .collect();
376 format!("…{tail}")
377}
378
379#[derive(Clone, Serialize, Deserialize)]
380struct User {
381 role: Role,
382 created: String,
383 last_seen: Option<String>,
384}
385
386#[derive(Clone, Serialize, Deserialize)]
387struct Invitation {
388 email: String,
389 role: Role,
390 created_by: String,
391 created: String,
392 expires: String,
393}
394
395#[derive(Clone, Serialize, Deserialize)]
396struct Link {
397 email: String,
398 created: String,
399 expires: String,
400}
401
402#[derive(Clone, Serialize, Deserialize)]
404struct Device {
405 user_code: String,
407 client: String,
408 created: String,
409 expires: String,
410 approved_by: Option<String>,
411 denied: bool,
412 token: Option<(String, String)>,
414}
415
416#[derive(Clone, Serialize, Deserialize)]
417struct Session {
418 email: String,
419 created: String,
420 expires: String,
421}
422
423#[derive(Clone, Serialize, Deserialize)]
424struct Token {
425 hash: String,
426 email: String,
427 name: String,
428 created: String,
429 last_used: Option<String>,
430 #[serde(default)]
431 expires: Option<String>,
432}
433
434impl Token {
435 fn expired(&self, now: &str) -> bool {
436 self.expires.as_deref().is_some_and(|e| e <= now)
437 }
438}
439
440#[derive(Clone, Serialize, Deserialize)]
441struct Secret {
442 sealed: String,
443 nonce: String,
444 hint: String,
445 updated: String,
446 updated_by: String,
447}
448
449#[derive(Clone, Serialize, Deserialize)]
450struct Code {
451 hash: String,
452 created: String,
453}
454
455#[derive(Default)]
457struct State {
458 users: BTreeMap<String, User>,
459 invites: HashMap<String, Invitation>,
460 links: HashMap<String, Link>,
461 sessions: HashMap<String, Session>,
462 tokens: HashMap<String, Token>,
463 secrets: BTreeMap<String, Secret>,
464 codes: HashMap<String, Code>,
465 seen: HashMap<String, String>,
467 config: HashMap<String, String>,
469 devices: HashMap<String, Device>,
470 jobs: HashMap<String, crate::jobs::JobRecord>,
471 job_events: HashMap<String, Vec<crate::events::Event>>,
472 failures: Vec<(String, String)>,
473}
474
475type Change = (&'static str, String, Option<String>);
477
478fn put(kind: &'static str, key: &str, value: &impl Serialize) -> Change {
479 (kind, key.to_string(), serde_json::to_string(value).ok())
480}
481
482fn del(kind: &'static str, key: &str) -> Change {
483 (kind, key.to_string(), None)
484}
485
486impl State {
487 fn load(&mut self, kind: &str, key: String, json: &str) {
488 fn add<T: serde::de::DeserializeOwned>(
489 m: &mut impl Extend<(String, T)>,
490 key: String,
491 json: &str,
492 ) {
493 if let Ok(v) = serde_json::from_str(json) {
494 m.extend([(key, v)]);
495 }
496 }
497 match kind {
498 "user" => add(&mut self.users, key, json),
499 "invite" => add(&mut self.invites, key, json),
500 "link" => add(&mut self.links, key, json),
501 "session" => add(&mut self.sessions, key, json),
502 "token" => add(&mut self.tokens, key, json),
503 "secret" => add(&mut self.secrets, key, json),
504 "code" => add(&mut self.codes, key, json),
505 "seen" => add(&mut self.seen, key, json),
506 "config" => add(&mut self.config, key, json),
507 "device" => add(&mut self.devices, key, json),
508 "job" => add(&mut self.jobs, key, json),
509 "job-events" => add(&mut self.job_events, key, json),
510 _ => {}
511 }
512 }
513
514 fn apply(&mut self, changes: &[Change]) {
516 for (kind, key, json) in changes {
517 match json {
518 Some(j) => self.load(kind, key.clone(), j),
519 None => match *kind {
520 "user" => drop(self.users.remove(key)),
521 "invite" => drop(self.invites.remove(key)),
522 "link" => drop(self.links.remove(key)),
523 "session" => drop(self.sessions.remove(key)),
524 "token" => drop(self.tokens.remove(key)),
525 "secret" => drop(self.secrets.remove(key)),
526 "code" => drop(self.codes.remove(key)),
527 "seen" => drop(self.seen.remove(key)),
528 "config" => drop(self.config.remove(key)),
529 "device" => drop(self.devices.remove(key)),
530 "job" => drop(self.jobs.remove(key)),
531 "job-events" => drop(self.job_events.remove(key)),
532 _ => {}
533 },
534 }
535 }
536 }
537}
538
539const TABLE: &str = "server";
540const CONFIG: &str = "config.toml";
541const REPOS: &str = "repos";
542const LOG_SKIP: &str = "log-skip";
543const KINDS: [&str; 12] = [
544 "user",
545 "invite",
546 "link",
547 "session",
548 "token",
549 "secret",
550 "code",
551 "seen",
552 "config",
553 "device",
554 "job",
555 "job-events",
556];
557
558fn run<T: Send + 'static>(
560 f: impl std::future::Future<Output = Result<T>> + Send + 'static,
561) -> Result<T> {
562 crate::rt::block_on(f)
563}
564
565fn schema() -> Arc<Schema> {
566 Arc::new(Schema::new(vec![
567 Field::new("kind", DataType::Utf8, false),
568 Field::new("key", DataType::Utf8, false),
569 Field::new("json", DataType::Utf8, false),
570 Field::new("mac", DataType::Utf8, false),
571 ]))
572}
573
574fn mac_key(secret: &str) -> [u8; 32] {
576 blake3::derive_key("comindly rows v1", secret.as_bytes())
577}
578
579fn mac(key: &[u8; 32], kind: &str, k: &str, json: &str) -> String {
580 blake3::keyed_hash(key, format!("{kind}\0{k}\0{json}").as_bytes())
581 .to_hex()
582 .to_string()
583}
584
585fn quote(s: &str) -> String {
586 format!("'{}'", s.replace('\'', "''"))
587}
588
589async fn table(uri: &str) -> Result<lancedb::Table> {
590 let db = crate::storage::connect(uri).execute().await?;
591 match db.open_table(TABLE).execute().await {
592 Ok(t) => Ok(t),
593 Err(_) => match db.create_empty_table(TABLE, schema()).execute().await {
594 Ok(t) => Ok(t),
595 Err(_) => Ok(db.open_table(TABLE).execute().await?),
597 },
598 }
599}
600
601async fn read_all(uri: String) -> Result<(u64, Vec<[String; 4]>)> {
603 read_rows(uri, "kind != 'audit'").await
604}
605
606async fn read_rows(uri: String, filter: &str) -> Result<(u64, Vec<[String; 4]>)> {
608 use futures::TryStreamExt;
609 use lancedb::query::{ExecutableQuery, QueryBase};
610 let tbl = table(&uri).await?;
611 let version = tbl.version().await?;
612 let mut st = tbl.query().only_if(filter).execute().await?;
613 let mut rows = Vec::new();
614 while let Some(b) = st.try_next().await? {
615 let col = |n: &str| {
616 b.column_by_name(n)
617 .and_then(|c| c.as_any().downcast_ref::<StringArray>().cloned())
618 };
619 let (Some(k), Some(key), Some(j), Some(m)) =
620 (col("kind"), col("key"), col("json"), col("mac"))
621 else {
622 continue;
623 };
624 for i in 0..b.num_rows() {
625 rows.push([k.value(i), key.value(i), j.value(i), m.value(i)].map(str::to_string));
626 }
627 }
628 Ok((version, rows))
629}
630
631async fn write(uri: String, key: [u8; 32], changes: Vec<Change>) -> Result<u64> {
632 let tbl = table(&uri).await?;
633 let (puts, dels): (Vec<_>, Vec<_>) = changes.into_iter().partition(|(_, _, j)| j.is_some());
634 if !dels.is_empty() {
635 let pred = dels
636 .iter()
637 .map(|(k, key, _)| format!("(kind = {} AND key = {})", quote(k), quote(key)))
638 .collect::<Vec<_>>()
639 .join(" OR ");
640 tbl.delete(&pred).await?;
641 }
642 if !puts.is_empty() {
643 let batch = RecordBatch::try_new(
644 schema(),
645 vec![
646 Arc::new(StringArray::from_iter_values(
647 puts.iter().map(|(k, _, _)| *k),
648 )),
649 Arc::new(StringArray::from_iter_values(
650 puts.iter().map(|(_, key, _)| key.as_str()),
651 )),
652 Arc::new(StringArray::from_iter_values(
653 puts.iter()
654 .map(|(_, _, j)| j.as_deref().unwrap_or_default()),
655 )),
656 Arc::new(StringArray::from_iter_values(puts.iter().map(
657 |(k, key_, j)| mac(&key, k, key_, j.as_deref().unwrap_or_default()),
658 ))),
659 ],
660 )?;
661 let reader = RecordBatchIterator::new(vec![Ok(batch)], schema());
662 let mut m = tbl.merge_insert(&["kind", "key"]);
663 m.when_matched_update_all(None)
664 .when_not_matched_insert_all();
665 m.execute(Box::new(reader)).await?;
666 }
667 Ok(tbl.version().await?)
668}
669
670async fn optimize(uri: String) -> Result<()> {
672 use lancedb::table::OptimizeAction;
673 let tbl = table(&uri).await?;
674 tbl.optimize(OptimizeAction::Compact {
675 options: Default::default(),
676 remap_options: None,
677 })
678 .await?;
679 tbl.optimize(OptimizeAction::Prune {
680 older_than: Duration::try_days(1),
681 delete_unverified: Some(false),
682 error_if_tagged_old_versions: Some(false),
683 })
684 .await?;
685 Ok(())
686}
687
688const REFRESH_SECONDS: i64 = 15;
689
690pub struct Store {
691 uri: String,
692 keys: Vec<[u8; 32]>,
694 key: String,
696 previous: Option<String>,
697 admins: Vec<String>,
698 admin_token: Option<String>,
699 state: Mutex<State>,
700 seen: Mutex<(u64, chrono::DateTime<Utc>)>,
702 writing: Mutex<()>,
704}
705
706impl Store {
707 pub fn open(index_root: &str, config: &ServerConfig) -> Result<Store> {
709 let mut store = Store::open_keyed(
710 index_root,
711 &config.secret_key,
712 config.secret_key_previous.as_deref(),
713 )?;
714 store.admins = config.admin_emails.clone();
715 store.admin_token = config.admin_token.clone();
716 Ok(store)
717 }
718
719 fn open_keyed(index_root: &str, key: &str, previous: Option<&str>) -> Result<Store> {
720 let store = Store::attach_keyed(index_root, key, previous)?;
721 store.rotate_key()?;
722 store.prune();
723 let unreadable = store.unreadable_secrets();
724 if !unreadable.is_empty() {
725 crate::ui::warn(&format!(
726 "{} stored secret(s) cannot be decrypted with COMINDLY_SECRET_KEY ({}); \
727 set COMINDLY_SECRET_KEY_PREVIOUS to the old key, or save them again",
728 unreadable.len(),
729 unreadable.join(", ")
730 ));
731 }
732 Ok(store)
733 }
734
735 fn attach_keyed(index_root: &str, key: &str, previous: Option<&str>) -> Result<Store> {
737 let uri = format!("{}/_server", index_root.trim_end_matches('/'));
738 let store = Store {
739 uri,
740 keys: [Some(key), previous]
741 .into_iter()
742 .flatten()
743 .map(mac_key)
744 .collect(),
745 key: key.to_string(),
746 previous: previous.map(str::to_string),
747 admins: Vec::new(),
748 admin_token: None,
749 state: Mutex::new(State::default()),
750 seen: Mutex::new((0, Utc::now())),
751 writing: Mutex::new(()),
752 };
753 store.reload()?;
754 Ok(store)
755 }
756
757 fn reload(&self) -> Result<()> {
760 let (version, rows) = run(read_all(self.uri.clone()))?;
761 let mut fresh = State::default();
762 let (mut forged, mut resign) = (0, Vec::new());
763 for [kind, key, json, m] in rows {
764 let Some(i) = self
765 .keys
766 .iter()
767 .position(|k| constant_eq(&mac(k, &kind, &key, &json), &m))
768 else {
769 forged += 1;
770 continue;
771 };
772 if i > 0 {
773 if let Some(k) = KINDS.iter().find(|k| **k == kind) {
774 resign.push((*k, key.clone(), Some(json.clone())));
775 }
776 }
777 fresh.load(&kind, key, &json);
778 }
779 if forged > 0 {
780 crate::ui::warn(&format!(
781 "ignored {forged} server row(s) not signed with COMINDLY_SECRET_KEY"
782 ));
783 }
784 let mut st = self.lock();
785 fresh.failures = std::mem::take(&mut st.failures);
786 *st = fresh;
787 *self.seen.lock().unwrap_or_else(|e| e.into_inner()) = (version, Utc::now());
788 drop(st);
789 self.commit(resign)
790 }
791
792 fn state(&self) -> std::sync::MutexGuard<'_, State> {
794 let stale = {
795 let seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
796 Utc::now() - seen.1 > Duration::seconds(REFRESH_SECONDS)
797 };
798 if stale {
799 let uri = self.uri.clone();
800 let version = run(async move { Ok(table(&uri).await?.version().await?) });
801 let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
802 seen.1 = Utc::now();
803 let changed = version.is_ok_and(|v| v != seen.0);
804 drop(seen);
805 if changed {
806 if let Err(e) = self.reload() {
807 crate::ui::warn(&format!("re-reading the server table: {e:#}"));
808 }
809 }
810 }
811 self.lock()
812 }
813
814 fn lock(&self) -> std::sync::MutexGuard<'_, State> {
815 self.state.lock().unwrap_or_else(|e| e.into_inner())
816 }
817
818 fn commit(&self, changes: Vec<Change>) -> Result<()> {
820 if changes.is_empty() {
821 return Ok(());
822 }
823 let version = run(write(self.uri.clone(), self.keys[0], changes.clone()))
824 .context("save to the server table")?;
825 self.lock().apply(&changes);
826 let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
827 if version == seen.0 + 1 || version == seen.0 + 2 {
828 seen.0 = version;
829 }
830 Ok(())
831 }
832
833 fn prune(&self) {
835 let t = now();
836 let day = (Utc::now() - Duration::days(1)).to_rfc3339();
837 let mut changes = Vec::new();
838 {
839 let mut st = self.state();
840 changes.extend(
841 st.links
842 .iter()
843 .filter(|(_, l)| l.expires < t)
844 .map(|(k, _)| del("link", k)),
845 );
846 changes.extend(
847 st.invites
848 .iter()
849 .filter(|(_, i)| i.expires < t)
850 .map(|(k, _)| del("invite", k)),
851 );
852 changes.extend(
853 st.sessions
854 .iter()
855 .filter(|(_, s)| s.expires < t)
856 .map(|(k, _)| del("session", k)),
857 );
858 changes.extend(
859 st.devices
860 .iter()
861 .filter(|(_, d)| d.expires < t)
862 .map(|(k, _)| del("device", k)),
863 );
864 changes.extend(long_expired(&st.tokens, |_| true));
865 st.failures.retain(|(_, at)| *at >= day);
866 }
867 if let Err(e) = self.commit(changes) {
868 crate::ui::warn(&format!("pruning the server table: {e:#}"));
869 }
870 if let Err(e) = run(optimize(self.uri.clone())) {
871 crate::ui::warn(&format!("optimizing the server table: {e:#}"));
872 }
873 }
874
875 fn unreadable_secrets(&self) -> Vec<String> {
877 self.state()
878 .secrets
879 .iter()
880 .filter(|(n, s)| unseal(&self.key, n, s).is_none())
881 .map(|(n, _)| n.clone())
882 .collect()
883 }
884
885 fn failures(&self, key: &str) -> i64 {
887 let since = (Utc::now() - Duration::minutes(LOCK_MINUTES)).to_rfc3339();
888 self.lock()
889 .failures
890 .iter()
891 .filter(|(k, at)| k == key && *at > since)
892 .count() as i64
893 }
894
895 fn fail(&self, key: &str) {
896 self.lock().failures.push((key.to_string(), now()));
897 }
898
899 fn forgive(&self, key: &str) {
900 self.lock().failures.retain(|(k, _)| k != key);
901 }
902
903 pub fn audit(&self, actor: &str, action: &str, detail: &str) {
904 let row =
905 serde_json::json!({"at": now(), "actor": actor, "action": action, "detail": detail});
906 let key = uuid::Uuid::new_v4().simple().to_string();
907 if let Err(e) = run(write(
908 self.uri.clone(),
909 self.keys[0],
910 vec![("audit", key, Some(row.to_string()))],
911 )) {
912 crate::ui::warn(&format!("audit log: {e:#}"));
913 }
914 }
915
916 fn audit_rows(&self) -> Result<Vec<(String, AuditEntry, String, usize)>> {
918 let (_, rows) = run(read_rows(self.uri.clone(), "kind = 'audit'"))?;
919 Ok(rows
920 .into_iter()
921 .filter_map(|[kind, key, json, m]| {
922 let i = self
923 .keys
924 .iter()
925 .position(|k| constant_eq(&mac(k, &kind, &key, &json), &m))?;
926 let v: serde_json::Value = serde_json::from_str(&json).ok()?;
927 let s = |n: &str| v[n].as_str().unwrap_or_default().to_string();
928 let entry = AuditEntry {
929 at: s("at"),
930 actor: s("actor"),
931 action: s("action"),
932 detail: s("detail"),
933 };
934 Some((key, entry, json, i))
935 })
936 .collect())
937 }
938
939 fn session_for(&self, email: &str) -> Result<(String, Who)> {
940 let who = self.member(email)?.context("not a member")?;
941 let token = new_token("ses_");
942 let session = Session {
943 email: who.email.clone(),
944 created: now(),
945 expires: (Utc::now() + Duration::days(SESSION_DAYS)).to_rfc3339(),
946 };
947 self.commit(vec![put("session", &hash(&token), &session)])?;
948 Ok((token, who))
949 }
950
951 fn rotate_key(&self) -> Result<()> {
953 let (key, Some(old)) = (self.key.clone(), self.previous.clone()) else {
954 return Ok(());
955 };
956 let rows: Vec<(String, Secret)> = self
957 .state()
958 .secrets
959 .iter()
960 .map(|(n, s)| (n.clone(), s.clone()))
961 .collect();
962 let mut changes = Vec::new();
963 for (name, row) in rows {
964 if unseal(&key, &name, &row).is_some() {
965 continue;
966 }
967 let Some(plain) = unseal(&old, &name, &row) else {
968 continue;
969 };
970 let mut again = sealed(&key, &name, &plain, &row.updated_by)?;
971 again.updated = row.updated;
972 changes.push(put("secret", &name, &again));
973 }
974 self.commit(changes)?;
975 let audit: Vec<Change> = self
976 .audit_rows()?
977 .into_iter()
978 .filter(|(.., i)| *i > 0)
979 .map(|(key, _, json, _)| ("audit", key, Some(json)))
980 .collect();
981 if !audit.is_empty() {
982 run(write(self.uri.clone(), self.keys[0], audit))?;
983 }
984 Ok(())
985 }
986
987 fn may_manage(&self, by: &str) -> Result<()> {
989 use crate::backends::AccountStore;
990 anyhow::ensure!(
991 self.member(by)?.is_some_and(|w| w.role == Role::Admin),
992 "only admins can do this"
993 );
994 Ok(())
995 }
996
997 fn write_lock(&self) -> std::sync::MutexGuard<'_, ()> {
998 self.writing.lock().unwrap_or_else(|e| e.into_inner())
999 }
1000}
1001
1002fn long_expired(tokens: &HashMap<String, Token>, whose: impl Fn(&str) -> bool) -> Vec<Change> {
1004 let gone = (Utc::now() - Duration::days(EXPIRED_TOKEN_DAYS)).to_rfc3339();
1005 tokens
1006 .iter()
1007 .filter(|(_, t)| whose(&t.email) && t.expired(&gone))
1008 .map(|(k, _)| del("token", k))
1009 .collect()
1010}
1011
1012fn sealed(key: &str, name: &str, value: &str, by: &str) -> Result<Secret> {
1013 let (s, n) = seal(key, name, value)?;
1014 Ok(Secret {
1015 sealed: hex(&s),
1016 nonce: hex(&n),
1017 hint: hint(value),
1018 updated: now(),
1019 updated_by: by.to_string(),
1020 })
1021}
1022
1023fn unseal(key: &str, name: &str, row: &Secret) -> Option<String> {
1024 open(key, name, &unhex(&row.sealed)?, &unhex(&row.nonce)?)
1025}
1026
1027fn hex(b: &[u8]) -> String {
1028 b.iter().map(|x| format!("{x:02x}")).collect()
1029}
1030
1031fn unhex(s: &str) -> Option<Vec<u8>> {
1032 (0..s.len())
1033 .step_by(2)
1034 .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
1035 .collect()
1036}
1037
1038pub(crate) fn constant_eq(a: &str, b: &str) -> bool {
1040 let (a, b) = (blake3::hash(a.as_bytes()), blake3::hash(b.as_bytes()));
1041 a.as_bytes()
1042 .iter()
1043 .zip(b.as_bytes())
1044 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
1045 == 0
1046}
1047
1048fn valid_email(email: &str) -> bool {
1049 email.contains('@') && !email.contains(|c: char| c.is_whitespace() || c == '|')
1050}
1051
1052pub fn git_secret(host: &str) -> String {
1054 format!("git:{}", host.to_lowercase())
1055}
1056
1057pub const LLM_SECRET: &str = "llm_api_key";
1058
1059impl crate::backends::AccountStore for Store {
1060 fn tidy(&self) {
1061 self.prune();
1062 }
1063
1064 fn member(&self, email: &str) -> Result<Option<Who>> {
1066 let email = normalize(email);
1067 if self.admins.contains(&email) {
1068 return Ok(Some(Who {
1069 email,
1070 role: Role::Admin,
1071 }));
1072 }
1073 let role = self.state().users.get(&email).map(|u| u.role);
1074 Ok(role.map(|role| Who { email, role }))
1075 }
1076
1077 fn members(&self) -> Result<Vec<Member>> {
1078 let admins = self.admins.clone();
1079 let st = self.state();
1080 let mut out: Vec<Member> = st
1081 .users
1082 .iter()
1083 .map(|(email, u)| Member {
1084 email: email.clone(),
1085 role: u.role,
1086 fixed: false,
1087 created: u.created.clone(),
1088 last_seen: u.last_seen.clone(),
1089 })
1090 .collect();
1091 for m in &mut out {
1092 if admins.contains(&m.email) {
1093 m.role = Role::Admin;
1094 m.fixed = true;
1095 }
1096 }
1097 for a in admins {
1098 if !out.iter().any(|m| m.email == a) {
1099 out.push(Member {
1100 last_seen: st.seen.get(&a).cloned(),
1101 email: a,
1102 role: Role::Admin,
1103 fixed: true,
1104 created: String::new(),
1105 });
1106 }
1107 }
1108 Ok(out)
1109 }
1110
1111 fn set_role(&self, by: &str, email: &str, role: Role) -> Result<()> {
1112 let email = normalize(email);
1113 if self.admins.contains(&email) {
1114 bail!("{email} is an admin by the deployment's settings");
1115 }
1116 let _w = self.write_lock();
1117 let user = self.state().users.get(&email).cloned();
1118 let mut user = user.with_context(|| format!("{email} is not a member"))?;
1119 self.may_manage(by)?;
1120 user.role = role;
1121 self.commit(vec![put("user", &email, &user)])?;
1122 self.audit(by, "role", &format!("{email} {}", role.as_str()));
1123 Ok(())
1124 }
1125
1126 fn remove(&self, by: &str, email: &str) -> Result<()> {
1128 let email = normalize(email);
1129 if self.admins.contains(&email) {
1130 bail!("{email} is an admin by the deployment's settings");
1131 }
1132 self.may_manage(by)?;
1133 let _w = self.write_lock();
1134 let mut changes = vec![del("user", &email), del("code", &email)];
1135 {
1136 let st = self.state();
1137 changes.extend(
1138 st.sessions
1139 .iter()
1140 .filter(|(_, s)| s.email == email)
1141 .map(|(k, _)| del("session", k)),
1142 );
1143 changes.extend(
1144 st.tokens
1145 .iter()
1146 .filter(|(_, t)| t.email == email)
1147 .map(|(k, _)| del("token", k)),
1148 );
1149 changes.extend(
1150 st.invites
1151 .iter()
1152 .filter(|(_, i)| i.email == email)
1153 .map(|(k, _)| del("invite", k)),
1154 );
1155 changes.extend(
1156 st.links
1157 .iter()
1158 .filter(|(_, l)| l.email == email)
1159 .map(|(k, _)| del("link", k)),
1160 );
1161 }
1162 self.commit(changes)?;
1163 let prefix = format!("code|{email}|");
1164 self.lock()
1165 .failures
1166 .retain(|(k, _)| !k.starts_with(&prefix));
1167 self.audit(by, "remove", &email);
1168 Ok(())
1169 }
1170
1171 fn invite(&self, by: &str, email: &str, role: Role, code: bool) -> Result<String> {
1173 let email = normalize(email);
1174 self.may_manage(by)?;
1175 anyhow::ensure!(
1176 email.contains('@') && !email.contains(char::is_whitespace),
1177 "that is not an email address"
1178 );
1179 let (token, h) = if code {
1180 let c = new_code();
1181 let h = code_hash(&c);
1182 (c, h)
1183 } else {
1184 let t = new_token("inv_");
1185 let h = hash(&t);
1186 (t, h)
1187 };
1188 let _w = self.write_lock();
1189 let mut changes: Vec<Change> = self
1190 .state()
1191 .invites
1192 .iter()
1193 .filter(|(_, i)| i.email == email)
1194 .map(|(k, _)| del("invite", k))
1195 .collect();
1196 changes.push(put(
1197 "invite",
1198 &h,
1199 &Invitation {
1200 email: email.clone(),
1201 role,
1202 created_by: by.to_string(),
1203 created: now(),
1204 expires: (Utc::now() + Duration::days(INVITE_DAYS)).to_rfc3339(),
1205 },
1206 ));
1207 self.commit(changes)?;
1208 self.audit(by, "invite", &format!("{email} {}", role.as_str()));
1209 Ok(token)
1210 }
1211
1212 fn invites(&self) -> Result<Vec<Invite>> {
1213 let t = now();
1214 let mut out: Vec<(String, Invite)> = self
1215 .state()
1216 .invites
1217 .values()
1218 .filter(|i| i.expires > t)
1219 .map(|i| {
1220 (
1221 i.created.clone(),
1222 Invite {
1223 email: i.email.clone(),
1224 role: i.role,
1225 created_by: i.created_by.clone(),
1226 expires: i.expires.clone(),
1227 },
1228 )
1229 })
1230 .collect();
1231 out.sort_by(|a, b| b.0.cmp(&a.0));
1232 Ok(out.into_iter().map(|(_, i)| i).collect())
1233 }
1234
1235 fn revoke_invite(&self, by: &str, email: &str) -> Result<()> {
1236 let email = normalize(email);
1237 self.may_manage(by)?;
1238 let _w = self.write_lock();
1239 let changes = self
1240 .state()
1241 .invites
1242 .iter()
1243 .filter(|(_, i)| i.email == email)
1244 .map(|(k, _)| del("invite", k))
1245 .collect();
1246 self.commit(changes)?;
1247 self.audit(by, "revoke-invite", &email);
1248 Ok(())
1249 }
1250
1251 fn sign_in_link(&self, email: &str) -> Result<Option<String>> {
1253 let Some(who) = self.member(email)? else {
1254 return Ok(None);
1255 };
1256 let hour = (Utc::now() - Duration::hours(1)).to_rfc3339();
1257 let recent = self
1258 .state()
1259 .links
1260 .values()
1261 .filter(|l| l.email == who.email && l.created > hour)
1262 .count() as i64;
1263 if recent >= MAX_LINKS_PER_HOUR {
1265 crate::ui::warn(&format!(
1266 "a member asked for more than {MAX_LINKS_PER_HOUR} sign-in links in an hour"
1267 ));
1268 return Ok(None);
1269 }
1270 let token = new_token("lnk_");
1271 let link = Link {
1272 email: who.email,
1273 created: now(),
1274 expires: (Utc::now() + Duration::minutes(LINK_MINUTES)).to_rfc3339(),
1275 };
1276 self.commit(vec![put("link", &hash(&token), &link)])?;
1277 Ok(Some(token))
1278 }
1279
1280 fn redeem(&self, token: &str, client: &str) -> Result<(String, Who)> {
1283 let token = token.trim();
1284 let from = format!("redeem|{client}");
1285 anyhow::ensure!(
1286 self.failures(&from) < MAX_REDEEM_FAILED,
1287 "Too many attempts from here; try again in {LOCK_MINUTES} minutes"
1288 );
1289 if let Some(admin) = &self.admin_token {
1290 if constant_eq(token, admin) {
1291 let email = self
1292 .admins
1293 .first()
1294 .cloned()
1295 .context("set COMINDLY_ADMIN_EMAILS to sign in with the admin token")?;
1296 self.audit(&email, "sign-in", "admin token");
1297 return self.session_for(&email);
1298 }
1299 }
1300 let h = hash(token);
1301 let t = now();
1302 let _w = self.write_lock();
1303 let link = self
1304 .state()
1305 .links
1306 .get(&h)
1307 .filter(|l| l.expires > t)
1308 .map(|l| l.email.clone());
1309 if let Some(email) = link {
1310 self.commit(vec![del("link", &h)])?;
1311 drop(_w);
1312 self.audit(&email, "sign-in", "email link");
1313 return self.session_for(&email);
1314 }
1315 let invite = self
1316 .state()
1317 .invites
1318 .get(&h)
1319 .filter(|i| i.expires > t)
1320 .cloned();
1321 if let Some(inv) = invite {
1322 let mut changes = vec![del("invite", &h)];
1323 if !self.state().users.contains_key(&inv.email) {
1324 changes.push(put(
1325 "user",
1326 &inv.email,
1327 &User {
1328 role: inv.role,
1329 created: t,
1330 last_seen: None,
1331 },
1332 ));
1333 }
1334 self.commit(changes)?;
1335 drop(_w);
1336 self.audit(&inv.email, "join", inv.role.as_str());
1337 return self.session_for(&inv.email);
1338 }
1339 drop(_w);
1340 self.fail(&from);
1341 bail!("this link has expired or was already used; ask for a new one")
1342 }
1343
1344 fn sign_in_code(
1349 &self,
1350 email: &str,
1351 code: &str,
1352 client: &str,
1353 ) -> Result<(String, Who, Option<String>)> {
1354 let email = normalize(email);
1355 anyhow::ensure!(valid_email(&email), "Wrong email or code");
1356 let from = format!("code|{email}|{client}");
1357 anyhow::ensure!(
1358 self.failures(&from) < MAX_FAILED,
1359 "Too many wrong codes for this address; try again in {LOCK_MINUTES} minutes"
1360 );
1361 let h = code_hash(code);
1362 let t = now();
1363 let _w = self.write_lock();
1364 let invite = self
1365 .state()
1366 .invites
1367 .get(&h)
1368 .filter(|i| i.email == email && i.expires > t)
1369 .cloned();
1370 if let Some(inv) = invite {
1371 let personal = new_code();
1372 let mut changes = vec![
1373 del("invite", &h),
1374 put(
1375 "code",
1376 &email,
1377 &Code {
1378 hash: code_hash(&personal),
1379 created: t.clone(),
1380 },
1381 ),
1382 ];
1383 if !self.state().users.contains_key(&email) {
1384 changes.push(put(
1385 "user",
1386 &email,
1387 &User {
1388 role: inv.role,
1389 created: t,
1390 last_seen: None,
1391 },
1392 ));
1393 }
1394 self.commit(changes)?;
1395 drop(_w);
1396 self.forgive(&from);
1397 self.audit(&email, "join", inv.role.as_str());
1398 let (session, who) = self.session_for(&email)?;
1399 return Ok((session, who, Some(personal)));
1400 }
1401 drop(_w);
1402 let known = self
1403 .state()
1404 .codes
1405 .get(&email)
1406 .is_some_and(|c| constant_eq(&c.hash, &h));
1407 if known && self.member(&email)?.is_some() {
1408 self.forgive(&from);
1409 self.audit(&email, "sign-in", "code");
1410 let (session, who) = self.session_for(&email)?;
1411 return Ok((session, who, None));
1412 }
1413 self.fail(&from);
1414 bail!("Wrong email or code")
1415 }
1416
1417 fn sign_in_verified(&self, email: &str, how: &str, join: bool) -> Result<(String, Who)> {
1420 let email = normalize(email);
1421 if self.member(&email)?.is_some() {
1422 self.audit(&email, "sign-in", how);
1423 return self.session_for(&email);
1424 }
1425 let who = self.admit(&email, join)?;
1426 self.session_for(&who.email)
1427 }
1428
1429 fn admit(&self, email: &str, join: bool) -> Result<Who> {
1430 let email = normalize(email);
1431 anyhow::ensure!(
1432 valid_email(&email),
1433 "the sign-in did not come with an email address"
1434 );
1435 if let Some(who) = self.member(&email)? {
1436 return Ok(who);
1437 }
1438 let t = now();
1439 let _w = self.write_lock();
1440 let invites: Vec<(String, Invitation)> = self
1441 .state()
1442 .invites
1443 .iter()
1444 .filter(|(_, i)| i.email == email)
1445 .map(|(k, i)| (k.clone(), i.clone()))
1446 .collect();
1447 let role = match invites.iter().find(|(_, i)| i.expires > t) {
1448 Some((_, i)) => i.role,
1449 None if join => Role::Viewer,
1450 None => bail!("The account {email} is not a member here; ask an admin to invite you"),
1451 };
1452 let mut changes: Vec<Change> = invites.iter().map(|(k, _)| del("invite", k)).collect();
1453 changes.push(put(
1454 "user",
1455 &email,
1456 &User {
1457 role,
1458 created: t,
1459 last_seen: None,
1460 },
1461 ));
1462 self.commit(changes)?;
1463 drop(_w);
1464 self.audit(&email, "join", role.as_str());
1465 Ok(Who { email, role })
1466 }
1467
1468 fn reset_code(&self, by: &str, email: &str, keep: Option<&str>) -> Result<String> {
1470 let email = normalize(email);
1471 anyhow::ensure!(self.member(&email)?.is_some(), "{email} is not a member");
1472 if by != email {
1473 anyhow::ensure!(
1474 !self.admins.contains(&email),
1475 "{email} is an admin by the deployment's settings and resets their own code"
1476 );
1477 self.may_manage(by)?;
1478 }
1479 let code = new_code();
1480 let keep = keep.map(hash).unwrap_or_default();
1481 let mut changes = vec![put(
1482 "code",
1483 &email,
1484 &Code {
1485 hash: code_hash(&code),
1486 created: now(),
1487 },
1488 )];
1489 changes.extend(
1490 self.state()
1491 .sessions
1492 .iter()
1493 .filter(|(k, s)| s.email == email && **k != keep)
1494 .map(|(k, _)| del("session", k)),
1495 );
1496 self.commit(changes)?;
1497 let prefix = format!("code|{email}|");
1498 self.lock()
1499 .failures
1500 .retain(|(k, _)| !k.starts_with(&prefix));
1501 self.audit(by, "reset-code", &email);
1502 Ok(code)
1503 }
1504
1505 fn sign_out(&self, session: &str, everywhere: bool) -> Result<()> {
1506 let h = hash(session);
1507 let (changes, email) = {
1508 let st = self.state();
1509 let email = st.sessions.get(&h).map(|s| s.email.clone());
1510 let changes = match (everywhere, st.sessions.get(&h)) {
1511 (true, Some(s)) => st
1512 .sessions
1513 .iter()
1514 .filter(|(_, x)| x.email == s.email)
1515 .map(|(k, _)| del("session", k))
1516 .collect(),
1517 _ => vec![del("session", &h)],
1518 };
1519 (changes, email)
1520 };
1521 self.commit(changes)?;
1522 if let Some(email) = email {
1523 self.audit(
1524 &email,
1525 "sign-out",
1526 if everywhere { "everywhere" } else { "" },
1527 );
1528 }
1529 Ok(())
1530 }
1531
1532 fn create_token(&self, who: &Who, name: &str, expires_in_days: Option<u32>) -> Result<String> {
1534 let name = name.trim();
1535 anyhow::ensure!(!name.is_empty(), "give the token a name");
1536 if let Some(d) = expires_in_days {
1537 anyhow::ensure!((1..=3650).contains(&d), "a token lasts 1 to 3650 days");
1538 }
1539 let token = new_token("cly_");
1540 let id = uuid::Uuid::new_v4().simple().to_string();
1541 let row = Token {
1542 hash: hash(&token),
1543 email: who.email.clone(),
1544 name: name.to_string(),
1545 created: now(),
1546 last_used: None,
1547 expires: expires_in_days.map(|d| (Utc::now() + Duration::days(d.into())).to_rfc3339()),
1548 };
1549 let mut changes = long_expired(&self.state().tokens, |e| e == who.email);
1550 changes.push(put("token", &id, &row));
1551 self.commit(changes)?;
1552 let lasts = match expires_in_days {
1553 Some(d) => format!("{name} ({d} days)"),
1554 None => format!("{name} (no expiry)"),
1555 };
1556 self.audit(&who.email, "token", &lasts);
1557 Ok(token)
1558 }
1559
1560 fn tokens(&self, who: &Who) -> Result<Vec<AccessToken>> {
1561 let t = now();
1562 let mut out: Vec<AccessToken> = self
1563 .state()
1564 .tokens
1565 .iter()
1566 .filter(|(_, tok)| tok.email == who.email)
1567 .map(|(id, tok)| AccessToken {
1568 id: id.clone(),
1569 name: tok.name.clone(),
1570 created: tok.created.clone(),
1571 last_used: tok.last_used.clone(),
1572 expires: tok.expires.clone(),
1573 expired: tok.expired(&t),
1574 })
1575 .collect();
1576 out.sort_by(|a, b| b.created.cmp(&a.created));
1577 Ok(out)
1578 }
1579
1580 fn revoke_token(&self, who: &Who, id: &str) -> Result<()> {
1581 let mine = self
1582 .state()
1583 .tokens
1584 .get(id)
1585 .filter(|t| t.email == who.email)
1586 .map(|t| t.name.clone());
1587 if let Some(name) = mine {
1588 self.commit(vec![del("token", id)])?;
1589 self.audit(&who.email, "revoke-token", &name);
1590 }
1591 Ok(())
1592 }
1593
1594 fn revoke_own_token(&self, token: &str) -> Result<bool> {
1595 let h = hash(token);
1596 let found = self
1597 .state()
1598 .tokens
1599 .iter()
1600 .find(|(_, t)| t.hash == h)
1601 .map(|(id, t)| (id.clone(), t.email.clone()));
1602 let Some((id, email)) = found else {
1603 return Ok(false);
1604 };
1605 self.commit(vec![del("token", &id)])?;
1606 self.audit(&email, "revoke-token", &id);
1607 Ok(true)
1608 }
1609}
1610
1611impl crate::backends::AuditLog for Store {
1612 fn record(&self, actor: &str, action: &str, detail: &str) {
1613 self.audit(actor, action, detail);
1614 }
1615
1616 fn audit_entries(&self, query: &AuditQuery) -> Result<AuditPage> {
1617 let limit = query.limit.unwrap_or(AUDIT_PAGE).clamp(1, AUDIT_PAGE_MAX);
1618 let before = query.before.as_deref().and_then(|b| b.split_once('|'));
1619 let actor = query
1620 .actor
1621 .as_deref()
1622 .map(normalize)
1623 .filter(|a| !a.is_empty());
1624 let action = query
1625 .action
1626 .as_deref()
1627 .map(str::trim)
1628 .filter(|a| !a.is_empty());
1629 let mut rows: Vec<(String, AuditEntry)> = self
1630 .audit_rows()?
1631 .into_iter()
1632 .map(|(key, e, ..)| (key, e))
1633 .filter(|(key, e)| {
1634 before.is_none_or(|(at, k)| (e.at.as_str(), key.as_str()) < (at, k))
1635 && actor.as_ref().is_none_or(|a| normalize(&e.actor) == *a)
1636 && action.is_none_or(|a| e.action == a)
1637 })
1638 .collect();
1639 rows.sort_unstable_by(|a, b| (&b.1.at, &b.0).cmp(&(&a.1.at, &a.0)));
1640 let next = (rows.len() > limit).then(|| {
1641 let (key, e) = &rows[limit - 1];
1642 format!("{}|{key}", e.at)
1643 });
1644 rows.truncate(limit);
1645 Ok(AuditPage {
1646 entries: rows.into_iter().map(|(_, e)| e).collect(),
1647 next,
1648 })
1649 }
1650}
1651
1652impl crate::backends::DeviceSignIn for Store {
1653 fn device_start(&self, client: &str, from: &str) -> Result<(String, String)> {
1654 let key = format!("device|{from}");
1655 anyhow::ensure!(
1656 self.failures(&key) < MAX_DEVICE_STARTS,
1657 "Too many sign-ins started from here; try again in {LOCK_MINUTES} minutes"
1658 );
1659 self.fail(&key);
1660 let client: String = client.trim().chars().take(80).collect();
1661 anyhow::ensure!(!client.is_empty(), "name the client signing in");
1662 let device_code = new_token("dev_");
1663 let user_code = new_code()[..9].to_string();
1664 let row = Device {
1665 user_code: code_hash(&user_code),
1666 client,
1667 created: now(),
1668 expires: (Utc::now() + Duration::minutes(DEVICE_MINUTES)).to_rfc3339(),
1669 approved_by: None,
1670 denied: false,
1671 token: None,
1672 };
1673 self.commit(vec![put("device", &hash(&device_code), &row)])?;
1674 Ok((device_code, user_code))
1675 }
1676
1677 fn device_info(&self, user_code: &str) -> Option<DeviceRequest> {
1678 let (h, t) = (code_hash(user_code), now());
1679 self.state()
1680 .devices
1681 .values()
1682 .find(|d| d.user_code == h && d.expires > t && d.approved_by.is_none() && !d.denied)
1683 .map(|d| DeviceRequest {
1684 client: d.client.clone(),
1685 expires: d.expires.clone(),
1686 })
1687 }
1688
1689 fn device_approve(&self, who: &Who, user_code: &str, approve: bool) -> Result<()> {
1690 let key = format!("approve|{}", who.email);
1691 anyhow::ensure!(
1692 self.failures(&key) < MAX_FAILED,
1693 "Too many wrong codes; try again in {LOCK_MINUTES} minutes"
1694 );
1695 let (h, t) = (code_hash(user_code), now());
1696 let _w = self.write_lock();
1697 let found = self
1698 .state()
1699 .devices
1700 .iter()
1701 .find(|(_, d)| {
1702 d.user_code == h && d.expires > t && d.approved_by.is_none() && !d.denied
1703 })
1704 .map(|(k, d)| (k.clone(), d.clone()));
1705 let Some((id, mut row)) = found else {
1706 self.fail(&key);
1707 bail!("That code is not waiting for approval; start the sign-in again");
1708 };
1709 if approve {
1710 let token = self.create_token(who, &row.client, Some(TOKEN_DAYS))?;
1711 let (sealed, nonce) = seal(&self.key, &format!("device:{id}"), &token)?;
1712 row.token = Some((hex(&sealed), hex(&nonce)));
1713 row.approved_by = Some(who.email.clone());
1714 } else {
1715 row.denied = true;
1716 }
1717 self.commit(vec![put("device", &id, &row)])?;
1718 self.audit(
1719 &who.email,
1720 if approve {
1721 "device-approved"
1722 } else {
1723 "device-denied"
1724 },
1725 &row.client,
1726 );
1727 Ok(())
1728 }
1729
1730 fn device_poll(&self, device_code: &str) -> Result<DevicePoll> {
1731 let id = hash(device_code.trim());
1732 let _w = self.write_lock();
1733 let Some(row) = self.state().devices.get(&id).cloned() else {
1734 return Ok(DevicePoll::Expired);
1735 };
1736 if row.denied {
1737 self.commit(vec![del("device", &id)])?;
1738 return Ok(DevicePoll::Denied);
1739 }
1740 if let (Some(email), Some((sealed, nonce))) = (&row.approved_by, &row.token) {
1741 let token = unhex(sealed)
1742 .zip(unhex(nonce))
1743 .and_then(|(s, n)| open(&self.key, &format!("device:{id}"), &s, &n))
1744 .context("the issued token cannot be read; start the sign-in again")?;
1745 self.commit(vec![del("device", &id)])?;
1746 let h = hash(&token);
1747 let expires = self
1748 .state()
1749 .tokens
1750 .values()
1751 .find(|t| t.hash == h)
1752 .and_then(|t| t.expires.clone());
1753 return Ok(DevicePoll::Approved {
1754 token,
1755 email: email.clone(),
1756 expires,
1757 });
1758 }
1759 Ok(if row.expires < now() {
1760 DevicePoll::Expired
1761 } else {
1762 DevicePoll::Pending
1763 })
1764 }
1765}
1766
1767impl crate::backends::Authenticator for Store {
1768 fn authenticate(
1770 &self,
1771 session: Option<&str>,
1772 bearer: Option<&str>,
1773 ) -> Result<Option<(Who, Via)>> {
1774 let t = now();
1775 let mut found: Option<(String, Via)> = None;
1776 let mut used: Option<(String, Token)> = None;
1777 if let Some(s) = session {
1778 found = self
1779 .state()
1780 .sessions
1781 .get(&hash(s))
1782 .filter(|s| s.expires > t)
1783 .map(|s| (s.email.clone(), Via::Session));
1784 }
1785 if found.is_none() {
1786 if let Some(b) = bearer {
1787 if self
1788 .admin_token
1789 .as_deref()
1790 .is_some_and(|a| constant_eq(b, a))
1791 {
1792 found = self.admins.first().map(|e| (e.clone(), Via::AdminToken));
1793 } else {
1794 let h = hash(b);
1795 let st = self.state();
1796 let live = st
1797 .tokens
1798 .iter()
1799 .find(|(_, k)| k.hash == h && !k.expired(&t));
1800 if let Some((id, tok)) = live {
1801 found = Some((tok.email.clone(), Via::Token));
1802 used = Some((id.clone(), tok.clone()));
1803 }
1804 }
1805 }
1806 }
1807 let Some((email, via)) = found else {
1808 return Ok(None);
1809 };
1810 let who = self.member(&email)?;
1811 if who.is_some() {
1812 let since = (Utc::now() - Duration::minutes(5)).to_rfc3339();
1814 let mut changes = Vec::new();
1815 if let Some((id, mut tok)) =
1816 used.filter(|(_, t)| t.last_used.as_ref().is_none_or(|u| *u < since))
1817 {
1818 tok.last_used = Some(t.clone());
1819 changes.push(put("token", &id, &tok));
1820 }
1821 let (user, seen) = {
1822 let st = self.state();
1823 (st.users.get(&email).cloned(), st.seen.get(&email).cloned())
1824 };
1825 if let Some(mut u) = user {
1826 if u.last_seen.as_ref().is_none_or(|s| *s < since) {
1827 u.last_seen = Some(t);
1828 changes.push(put("user", &email, &u));
1829 }
1830 } else if seen.is_none_or(|s| s < since) {
1831 changes.push(put("seen", &email, &t));
1832 }
1833 if let Err(e) = self.commit(changes) {
1834 crate::ui::warn(&format!("recording last use: {e:#}"));
1835 }
1836 }
1837 Ok(who.map(|w| (w, via)))
1838 }
1839}
1840
1841impl crate::backends::SecretStore for Store {
1842 fn set_secret(&self, by: &str, name: &str, value: &str) -> Result<()> {
1844 let value = value.trim();
1845 if value.is_empty() {
1846 self.commit(vec![del("secret", name)])?;
1847 self.audit(by, "secret-removed", name);
1848 return Ok(());
1849 }
1850 let row = sealed(&self.key, name, value, by)?;
1851 self.commit(vec![put("secret", name, &row)])?;
1852 self.audit(by, "secret-set", name);
1853 Ok(())
1854 }
1855
1856 fn secret(&self, name: &str) -> Option<String> {
1857 let row = self.state().secrets.get(name).cloned()?;
1858 unseal(&self.key, name, &row)
1859 }
1860
1861 fn secret_info(&self, name: &str) -> Option<SecretInfo> {
1862 self.state().secrets.get(name).map(|s| SecretInfo {
1863 name: name.to_string(),
1864 source: "stored".into(),
1865 hint: Some(s.hint.clone()),
1866 updated: Some(s.updated.clone()),
1867 updated_by: Some(s.updated_by.clone()),
1868 })
1869 }
1870
1871 fn secret_names(&self) -> Vec<String> {
1873 self.state().secrets.keys().cloned().collect()
1874 }
1875}
1876
1877impl crate::backends::SettingsStore for Store {
1878 fn repos(&self) -> Vec<crate::config::RepoEntry> {
1879 self.state()
1880 .config
1881 .get(REPOS)
1882 .and_then(|j| serde_json::from_str(j).ok())
1883 .unwrap_or_default()
1884 }
1885
1886 fn set_repos(&self, by: &str, repos: &[crate::config::RepoEntry]) -> Result<()> {
1887 self.commit(vec![put("config", REPOS, &serde_json::to_string(repos)?)])?;
1888 self.audit(by, "repos", &repos.len().to_string());
1889 Ok(())
1890 }
1891
1892 fn legacy_config(&self) -> Option<String> {
1893 self.state().config.get(CONFIG).cloned()
1894 }
1895
1896 fn drop_legacy_config(&self) -> Result<()> {
1897 self.commit(vec![del("config", CONFIG)])
1898 }
1899
1900 fn log_skip(&self) -> Option<String> {
1901 self.state().config.get(LOG_SKIP).cloned()
1902 }
1903
1904 fn set_log_skip(&self, by: &str, skip: &str) -> Result<()> {
1905 self.commit(vec![put("config", LOG_SKIP, &skip.to_string())])?;
1906 self.audit(by, "logging", if skip.is_empty() { "all" } else { skip });
1907 Ok(())
1908 }
1909}
1910
1911impl crate::jobs::JobStore for Store {
1912 fn save(&self, job: &crate::jobs::JobRecord, events: &[crate::events::Event]) -> Result<()> {
1913 let mut changes = vec![
1914 put("job", &job.id, job),
1915 put("job-events", &job.id, &events),
1916 ];
1917 {
1918 let st = self.state();
1919 let mut ids: Vec<(&String, &String)> = st
1920 .jobs
1921 .iter()
1922 .filter(|(id, _)| **id != job.id)
1923 .map(|(id, j)| (&j.created, id))
1924 .collect();
1925 ids.sort_unstable_by(|a, b| b.cmp(a));
1926 for (_, id) in ids.into_iter().skip(crate::jobs::MAX_JOBS - 1) {
1927 changes.extend([del("job", id), del("job-events", id)]);
1928 }
1929 }
1930 let _w = self.write_lock();
1931 self.commit(changes)
1932 }
1933
1934 fn get(&self, id: &str) -> Option<(crate::jobs::JobRecord, Vec<crate::events::Event>)> {
1935 let st = self.state();
1936 let job = st.jobs.get(id)?.clone();
1937 Some((job, st.job_events.get(id).cloned().unwrap_or_default()))
1938 }
1939
1940 fn list(&self, limit: usize) -> Vec<crate::jobs::JobRecord> {
1941 let mut jobs: Vec<_> = self.state().jobs.values().cloned().collect();
1942 jobs.sort_unstable_by(|a, b| b.created.cmp(&a.created));
1943 jobs.truncate(limit);
1944 jobs
1945 }
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950 use super::*;
1951
1952 fn temp_store() -> ((), Store) {
1953 let dir = std::env::temp_dir().join(format!("comindly-server-{}", uuid::Uuid::new_v4()));
1954 ((), {
1955 let mut s = Store::open_keyed(dir.to_str().unwrap(), "test key", None).unwrap();
1956 s.admins = vec!["a".into(), "a@x.org".into(), "admin@x.org".into()];
1957 s
1958 })
1959 }
1960
1961 #[test]
1962 fn invitations_are_single_use_and_tied_to_their_email() {
1963 let (_d, s) = temp_store();
1964 let token = s
1965 .invite("admin@x.org", "Dev@X.org ", Role::Member, false)
1966 .unwrap();
1967 let (session, who) = s.redeem(&token, "10.0.0.1").unwrap();
1968 assert_eq!(who.email, "dev@x.org");
1969 assert_eq!(who.role, Role::Member);
1970 assert!(s.redeem(&token, "10.0.0.1").is_err());
1971 let (back, _) = s.authenticate(Some(&session), None).unwrap().unwrap();
1972 assert_eq!(back.email, "dev@x.org");
1973 s.sign_out(&session, false).unwrap();
1974 assert!(s.authenticate(Some(&session), None).unwrap().is_none());
1975 }
1976
1977 #[test]
1978 fn sign_in_methods_combine_and_need_their_setup() {
1979 use SignIn::*;
1980 let m = |set, smtp, oidc| sign_in_methods(set, smtp, oidc);
1981 assert_eq!(m(None, false, false).unwrap(), vec![Code]);
1982 assert_eq!(m(None, true, true).unwrap(), vec![Oidc, Email]);
1983 assert_eq!(
1984 m(Some("code, email,oidc"), true, true).unwrap(),
1985 vec![Oidc, Email, Code]
1986 );
1987 assert!(m(Some("email"), false, true).is_err());
1988 assert!(m(Some("oidc,code"), true, false).is_err());
1989 assert!(m(Some("password"), true, true).is_err());
1990 assert!(m(Some(","), true, true).is_err());
1991 }
1992
1993 #[test]
1994 fn a_provider_sign_in_admits_members_the_invited_and_allowed_domains_only() {
1995 let (_d, s) = temp_store();
1996 assert!(s.sign_in_verified("stranger@x.org", "oidc", false).is_err());
1997 assert!(s.member("stranger@x.org").unwrap().is_none());
1998
1999 let (_, who) = s.sign_in_verified("viewer@x.org", "oidc", true).unwrap();
2000 assert_eq!(who.role, Role::Viewer);
2001
2002 s.invite("admin@x.org", "dev@x.org", Role::Member, true)
2003 .unwrap();
2004 let (session, who) = s.sign_in_verified("Dev@X.org", "oidc", false).unwrap();
2005 assert_eq!((who.email.as_str(), who.role), ("dev@x.org", Role::Member));
2006 assert!(s.invites().unwrap().is_empty(), "the invitation is used up");
2007 assert!(s.authenticate(Some(&session), None).unwrap().is_some());
2008
2009 s.set_role("admin@x.org", "dev@x.org", Role::Admin).unwrap();
2010 let (_, who) = s.sign_in_verified("dev@x.org", "oidc", false).unwrap();
2011 assert_eq!(who.role, Role::Admin, "a member keeps the role given here");
2012 assert!(s.sign_in_verified("", "oidc", true).is_err());
2013 }
2014
2015 #[test]
2016 fn the_gateway_admits_the_invited_without_a_session() {
2017 let (_d, s) = temp_store();
2018 assert!(s.admit("stranger@x.org", false).is_err());
2019 s.invite("admin@x.org", "dev@x.org", Role::Member, true)
2020 .unwrap();
2021 let who = s.admit("Dev@X.org", false).unwrap();
2022 assert_eq!((who.email.as_str(), who.role), ("dev@x.org", Role::Member));
2023 assert!(s.invites().unwrap().is_empty(), "the invitation is used up");
2024 assert_eq!(s.admit("dev@x.org", false).unwrap().role, Role::Member);
2025 assert_eq!(s.admit("new@x.org", true).unwrap().role, Role::Viewer);
2026 }
2027
2028 #[test]
2029 fn a_command_line_sign_in_is_approved_once_and_collected_once() {
2030 use crate::backends::DeviceSignIn;
2031 let (_d, s) = temp_store();
2032 let invite = s.invite("a@x.org", "m@x.org", Role::Member, false).unwrap();
2033 let (_, who) = s.redeem(&invite, "1.2.3.4").unwrap();
2034 let (device, user) = s.device_start("comindly CLI on laptop", "1.2.3.4").unwrap();
2035 assert_eq!(s.device_poll(&device).unwrap(), DevicePoll::Pending);
2036 assert_eq!(
2037 s.device_info(&user.to_lowercase()).unwrap().client,
2038 "comindly CLI on laptop"
2039 );
2040 assert!(s.device_approve(&who, "AAAA-AAAA", true).is_err());
2041 s.device_approve(&who, &user, true).unwrap();
2042 assert!(s.device_info(&user).is_none(), "approved once");
2043 let DevicePoll::Approved { token, email, .. } = s.device_poll(&device).unwrap() else {
2044 panic!("not approved");
2045 };
2046 assert_eq!(email, "m@x.org");
2047 assert!(s.authenticate(None, Some(&token)).unwrap().is_some());
2048 assert_eq!(
2049 s.device_poll(&device).unwrap(),
2050 DevicePoll::Expired,
2051 "collected once"
2052 );
2053
2054 let (device, user) = s.device_start("other", "1.2.3.4").unwrap();
2055 s.device_approve(&who, &user, false).unwrap();
2056 assert_eq!(s.device_poll(&device).unwrap(), DevicePoll::Denied);
2057 }
2058
2059 #[test]
2060 fn only_admins_manage_people() {
2061 let (_d, s) = temp_store();
2062 let join = |email: &str, role| {
2063 let inv = s.invite("a", email, role, false).unwrap();
2064 s.redeem(&inv, "1.2.3.4").unwrap().1
2065 };
2066 let admin = join("adm@x.org", Role::Admin);
2067 join("mem@x.org", Role::Member);
2068 join("view@x.org", Role::Viewer);
2069 assert!(s
2070 .invite(&admin.email, "x@x.org", Role::Admin, false)
2071 .is_ok());
2072 assert!(s.set_role(&admin.email, "mem@x.org", Role::Viewer).is_ok());
2073 assert!(s.set_role("mem@x.org", "view@x.org", Role::Member).is_err());
2074 assert!(s
2075 .invite("view@x.org", "y@x.org", Role::Viewer, false)
2076 .is_err());
2077 assert!(s.remove("mem@x.org", "adm@x.org").is_err());
2078 assert!(
2079 s.remove(&admin.email, "a").is_err(),
2080 "deployment admins stay"
2081 );
2082 assert!(s.remove(&admin.email, "view@x.org").is_ok());
2083 }
2084
2085 #[test]
2086 fn sign_in_links_only_for_members_and_once() {
2087 let (_d, s) = temp_store();
2088 assert!(s.sign_in_link("stranger@x.org").unwrap().is_none());
2089 let inv = s.invite("a", "m@x.org", Role::Member, false).unwrap();
2090 s.redeem(&inv, "10.0.0.1").unwrap();
2091 let link = s.sign_in_link("m@x.org").unwrap().unwrap();
2092 assert!(s.redeem(&link, "10.0.0.1").is_ok());
2093 assert!(s.redeem(&link, "10.0.0.1").is_err());
2094 }
2095
2096 #[test]
2097 fn removing_a_member_ends_their_tokens() {
2098 let (_d, s) = temp_store();
2099 let inv = s.invite("a", "m@x.org", Role::Member, false).unwrap();
2100 let (_, who) = s.redeem(&inv, "10.0.0.1").unwrap();
2101 let token = s.create_token(&who, "laptop", None).unwrap();
2102 assert!(s.authenticate(None, Some(&token)).unwrap().is_some());
2103 s.remove("a", "m@x.org").unwrap();
2104 assert!(s.authenticate(None, Some(&token)).unwrap().is_none());
2105 }
2106
2107 #[test]
2108 fn a_token_revokes_itself_and_no_other() {
2109 let (_d, s) = temp_store();
2110 let inv = s.invite("a", "m@x.org", Role::Member, false).unwrap();
2111 let (_, who) = s.redeem(&inv, "10.0.0.1").unwrap();
2112 let laptop = s.create_token(&who, "laptop", None).unwrap();
2113 let ci = s.create_token(&who, "ci", None).unwrap();
2114 assert!(s.revoke_own_token(&laptop).unwrap());
2115 assert!(s.authenticate(None, Some(&laptop)).unwrap().is_none());
2116 assert!(s.authenticate(None, Some(&ci)).unwrap().is_some());
2117 assert!(!s.revoke_own_token(&laptop).unwrap(), "already gone");
2118 assert!(!s.revoke_own_token("cly_unknown").unwrap());
2119 }
2120
2121 #[test]
2122 fn invitation_codes_need_their_own_address_and_give_a_sign_in_code() {
2123 let (_d, s) = temp_store();
2124 let invite = s.invite("a", "m@x.org", Role::Member, true).unwrap();
2125 assert!(s.sign_in_code("other@x.org", &invite, "10.0.0.1").is_err());
2126 let typed = invite.to_lowercase().replace('-', " ");
2127 let (_, who, personal) = s.sign_in_code("M@x.org", &typed, "10.0.0.1").unwrap();
2128 assert_eq!(who.email, "m@x.org");
2129 let personal = personal.unwrap();
2130 assert!(s.sign_in_code("m@x.org", &invite, "10.0.0.1").is_err());
2131 let (_, _, again) = s.sign_in_code("m@x.org", &personal, "10.0.0.1").unwrap();
2132 assert!(again.is_none());
2133 }
2134
2135 #[test]
2136 fn a_reset_code_replaces_the_old_one_and_ends_other_sessions() {
2137 let (_d, s) = temp_store();
2138 let invite = s.invite("a", "m@x.org", Role::Member, true).unwrap();
2139 let (mine, _, old) = s.sign_in_code("m@x.org", &invite, "10.0.0.1").unwrap();
2140 let (elsewhere, _, _) = s
2141 .sign_in_code("m@x.org", old.as_ref().unwrap(), "10.0.0.1")
2142 .unwrap();
2143 let new = s.reset_code("m@x.org", "m@x.org", Some(&mine)).unwrap();
2144 assert!(s.authenticate(Some(&mine), None).unwrap().is_some());
2145 assert!(s.authenticate(Some(&elsewhere), None).unwrap().is_none());
2146 assert!(s
2147 .sign_in_code("m@x.org", old.as_ref().unwrap(), "10.0.0.1")
2148 .is_err());
2149 assert!(s.sign_in_code("m@x.org", &new, "10.0.0.1").is_ok());
2150 }
2151
2152 #[test]
2153 fn wrong_codes_lock_the_address_for_that_client() {
2154 let (_d, s) = temp_store();
2155 let invite = s.invite("a", "m@x.org", Role::Member, true).unwrap();
2156 for _ in 0..MAX_FAILED {
2157 assert!(s.sign_in_code("m@x.org", "AAAA-AAAA", "10.0.0.1").is_err());
2158 }
2159 let e = s
2160 .sign_in_code("m@x.org", &invite, "10.0.0.1")
2161 .unwrap_err()
2162 .to_string();
2163 assert!(e.contains("Too many"), "{e}");
2164 assert!(s.sign_in_code("m@x.org", &invite, "10.0.0.2").is_ok());
2165 }
2166
2167 #[test]
2168 fn accounts_survive_a_restart_and_rows_without_the_key_are_ignored() {
2169 let dir = std::env::temp_dir().join(format!("comindly-server-{}", uuid::Uuid::new_v4()));
2170 let root = dir.to_str().unwrap();
2171 let mut s = Store::open_keyed(root, "key one", None).unwrap();
2172 s.admins = vec!["admin@x.org".into()];
2173 let code = s
2174 .invite("admin@x.org", "dev@x.org", Role::Member, true)
2175 .unwrap();
2176 let (_, _, personal) = s.sign_in_code("dev@x.org", &code, "1.2.3.4").unwrap();
2177 let token = s
2178 .create_token(&s.member("dev@x.org").unwrap().unwrap(), "agent", None)
2179 .unwrap();
2180 drop(s);
2181
2182 let again = Store::open_keyed(root, "key one", None).unwrap();
2183 assert!(again
2184 .sign_in_code("dev@x.org", &personal.unwrap(), "1.2.3.4")
2185 .is_ok());
2186 assert!(again.authenticate(None, Some(&token)).unwrap().is_some());
2187
2188 let forged = vec![put(
2189 "user",
2190 "eve@x.org",
2191 &User {
2192 role: Role::Admin,
2193 created: now(),
2194 last_seen: None,
2195 },
2196 )];
2197 run(write(
2198 again.uri.clone(),
2199 mac_key("someone else's key"),
2200 forged,
2201 ))
2202 .unwrap();
2203 again.reload().unwrap();
2204 assert!(again.member("eve@x.org").unwrap().is_none());
2205
2206 let rotated = Store::open_keyed(root, "key two", Some("key one")).unwrap();
2207 assert!(rotated.authenticate(None, Some(&token)).unwrap().is_some());
2208 let only_new = Store::open_keyed(root, "key two", None).unwrap();
2209 assert!(only_new.authenticate(None, Some(&token)).unwrap().is_some());
2210 }
2211
2212 fn expire(s: &Store, id: &str, days: i64) {
2214 let mut tok = s.state().tokens.get(id).cloned().unwrap();
2215 tok.expires = Some((Utc::now() + Duration::days(days)).to_rfc3339());
2216 s.commit(vec![put("token", id, &tok)]).unwrap();
2217 }
2218
2219 #[test]
2220 fn expired_tokens_stop_working_stay_listed_then_go() {
2221 let (_d, s) = temp_store();
2222 let who = s.member("a@x.org").unwrap().unwrap();
2223 let token = s.create_token(&who, "agent", Some(30)).unwrap();
2224 let forever = s.create_token(&who, "forever", None).unwrap();
2225 assert!(s.create_token(&who, "zero", Some(0)).is_err());
2226 let list = s.tokens(&who).unwrap();
2227 let agent = list.iter().find(|t| t.name == "agent").unwrap();
2228 assert!(agent.expires.is_some() && !agent.expired);
2229 assert!(list
2230 .iter()
2231 .find(|t| t.name == "forever")
2232 .unwrap()
2233 .expires
2234 .is_none());
2235 let id = agent.id.clone();
2236
2237 expire(&s, &id, -1);
2238 assert!(s.authenticate(None, Some(&token)).unwrap().is_none());
2239 assert!(s.authenticate(None, Some(&forever)).unwrap().is_some());
2240 assert!(s
2241 .tokens(&who)
2242 .unwrap()
2243 .iter()
2244 .any(|t| t.id == id && t.expired));
2245 s.prune();
2246 assert!(
2247 s.tokens(&who).unwrap().iter().any(|t| t.id == id),
2248 "kept a while"
2249 );
2250
2251 expire(&s, &id, -(EXPIRED_TOKEN_DAYS + 1));
2252 s.prune();
2253 let left = s.tokens(&who).unwrap();
2254 assert!(!left.iter().any(|t| t.id == id), "pruned later");
2255 assert_eq!(left.len(), 1);
2256 }
2257
2258 #[test]
2259 fn tokens_stored_without_an_expiry_never_expire() {
2260 let (_d, s) = temp_store();
2261 let row = r#"{"hash":"h","email":"a@x.org","name":"old","created":"2025-01-01T00:00:00+00:00","last_used":null}"#;
2262 let tok: Token = serde_json::from_str(row).unwrap();
2263 assert!(tok.expires.is_none() && !tok.expired(&now()));
2264 let who = s.member("a@x.org").unwrap().unwrap();
2265 s.commit(vec![put("token", "old", &tok)]).unwrap();
2266 let t = s.tokens(&who).unwrap();
2267 assert!(t[0].expires.is_none() && !t[0].expired);
2268 }
2269
2270 #[test]
2271 fn a_command_line_token_expires_by_default() {
2272 use crate::backends::DeviceSignIn;
2273 let (_d, s) = temp_store();
2274 let who = s.member("a@x.org").unwrap().unwrap();
2275 let (device, user) = s.device_start("comindly CLI on laptop", "1.2.3.4").unwrap();
2276 s.device_approve(&who, &user, true).unwrap();
2277 let DevicePoll::Approved { expires, .. } = s.device_poll(&device).unwrap() else {
2278 panic!("not approved");
2279 };
2280 let expires = expires.expect("an expiry");
2281 let in_days = chrono::DateTime::parse_from_rfc3339(&expires)
2282 .unwrap()
2283 .to_utc()
2284 - Utc::now();
2285 assert_eq!(in_days.num_days(), i64::from(TOKEN_DAYS) - 1);
2286 }
2287
2288 #[test]
2289 fn the_audit_log_lists_newest_first_in_pages_and_filters() {
2290 use crate::backends::AuditLog;
2291 let (_d, s) = temp_store();
2292 for i in 0..5 {
2293 s.audit("a@x.org", "secret-set", &format!("n{i}"));
2294 }
2295 s.audit("b@x.org", "repos", "2");
2296 let token = s
2297 .create_token(&s.member("a@x.org").unwrap().unwrap(), "agent", Some(30))
2298 .unwrap();
2299 let all = s.audit_entries(&AuditQuery::default()).unwrap();
2300 assert_eq!(all.entries.len(), 7);
2301 assert!(all.next.is_none());
2302 assert!(all.entries.windows(2).all(|w| w[0].at >= w[1].at));
2303 assert_eq!(all.entries[0].action, "token");
2304 assert!(all.entries.iter().all(|e| !e.detail.contains(&token)));
2305
2306 let first = s
2307 .audit_entries(&AuditQuery {
2308 limit: Some(3),
2309 ..Default::default()
2310 })
2311 .unwrap();
2312 assert_eq!(first.entries.len(), 3);
2313 let second = s
2314 .audit_entries(&AuditQuery {
2315 limit: Some(3),
2316 before: first.next.clone(),
2317 ..Default::default()
2318 })
2319 .unwrap();
2320 let third = s
2321 .audit_entries(&AuditQuery {
2322 limit: Some(3),
2323 before: second.next.clone(),
2324 ..Default::default()
2325 })
2326 .unwrap();
2327 assert!(third.next.is_none());
2328 let paged: Vec<_> = [first, second, third]
2329 .into_iter()
2330 .flat_map(|p| p.entries)
2331 .map(|e| (e.at, e.detail))
2332 .collect();
2333 let whole: Vec<_> = all.entries.into_iter().map(|e| (e.at, e.detail)).collect();
2334 assert_eq!(paged, whole);
2335
2336 let by = |actor: Option<&str>, action: Option<&str>| {
2337 s.audit_entries(&AuditQuery {
2338 actor: actor.map(str::to_string),
2339 action: action.map(str::to_string),
2340 ..Default::default()
2341 })
2342 .unwrap()
2343 .entries
2344 .len()
2345 };
2346 assert_eq!(by(Some("B@x.org"), None), 1);
2347 assert_eq!(by(None, Some("secret-set")), 5);
2348 assert_eq!(by(Some("a@x.org"), Some("repos")), 0);
2349 }
2350
2351 #[test]
2352 fn audit_rows_survive_a_key_rotation() {
2353 use crate::backends::AuditLog;
2354 let dir = std::env::temp_dir().join(format!("comindly-server-{}", uuid::Uuid::new_v4()));
2355 let root = dir.to_str().unwrap();
2356 Store::open_keyed(root, "key one", None)
2357 .unwrap()
2358 .audit("a@x.org", "repos", "1");
2359 drop(Store::open_keyed(root, "key two", Some("key one")).unwrap());
2360 let only_new = Store::open_keyed(root, "key two", None).unwrap();
2361 assert_eq!(
2362 only_new
2363 .audit_entries(&AuditQuery::default())
2364 .unwrap()
2365 .entries
2366 .len(),
2367 1
2368 );
2369 }
2370
2371 #[test]
2372 fn codes_are_twenty_characters_in_groups() {
2373 let c = new_code();
2374 assert_eq!(c.len(), 24);
2375 assert!(c.split('-').all(|g| g.len() == 4));
2376 assert_ne!(c, new_code());
2377 }
2378
2379 #[test]
2380 fn sealed_values_open_only_with_their_key() {
2381 let (sealed, nonce) = seal("key-one", "git:gitlab.com", "glpat-example").unwrap();
2382 assert_eq!(
2383 open("key-one", "git:gitlab.com", &sealed, &nonce).as_deref(),
2384 Some("glpat-example")
2385 );
2386 assert!(open("key-two", "git:gitlab.com", &sealed, &nonce).is_none());
2387 assert!(open("key-one", "llm_api_key", &sealed, &nonce).is_none());
2388 assert_eq!(hint("glpat-example"), "…mple");
2389 }
2390
2391 #[test]
2392 fn values_sealed_by_earlier_versions_still_open() {
2393 let unhex = |s: &str| {
2395 (0..s.len())
2396 .step_by(2)
2397 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
2398 .collect::<Vec<u8>>()
2399 };
2400 let sealed = unhex("04558269f76990598e21e0fa73bff46e3b8778dc6671e982ac0d0096b4");
2401 let nonce = unhex("c7063968b8df63bf60ecae5b3deba9969d937912cc1b4726");
2402 assert_eq!(
2403 open("fixture-key", "git:example.com", &sealed, &nonce).as_deref(),
2404 Some("example-value")
2405 );
2406 }
2407}