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