1use super::{AuthDoc, AuthTable};
40use crate::error::{Error, Result};
41use crate::model::{Level, PrincipalKind, Scope};
42
43pub const OPERATOR_REQUEST_TTL_MS: i64 = 3 * 24 * 3_600_000;
45pub const OPERATOR_ACCESS_MAX_MINUTES: u32 = 7 * 24 * 60;
47pub const OPERATOR_ACCESS_DEFAULT_MINUTES: u32 = 60;
49const MAX_REASON_CHARS: usize = 500;
51
52#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
54pub enum OperatorAccessState {
55 Pending,
57 Approved,
59 Refused,
61 Revoked,
63 Expired,
65}
66
67impl OperatorAccessState {
68 pub fn as_str(self) -> &'static str {
70 match self {
71 OperatorAccessState::Pending => "pending",
72 OperatorAccessState::Approved => "approved",
73 OperatorAccessState::Refused => "refused",
74 OperatorAccessState::Revoked => "revoked",
75 OperatorAccessState::Expired => "expired",
76 }
77 }
78}
79
80#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct OperatorAccess {
84 pub id: String,
86 pub code: String,
89 pub account: String,
91 pub operator: String,
93 pub reason: String,
95 pub minutes: u32,
97 pub requested_at_unix_ms: i64,
99 pub request_expires_at_unix_ms: i64,
101 pub state: OperatorAccessState,
103 #[serde(default)]
105 pub granted_until_unix_ms: i64,
106 #[serde(default)]
108 pub decided_by: String,
109 #[serde(default)]
111 pub decided_at_unix_ms: i64,
112}
113
114impl OperatorAccess {
115 pub fn is_live_at(&self, now: i64) -> bool {
117 self.state == OperatorAccessState::Approved && now < self.granted_until_unix_ms
118 }
119
120 pub fn state_at(&self, now: i64) -> OperatorAccessState {
122 match self.state {
123 OperatorAccessState::Pending if now >= self.request_expires_at_unix_ms => OperatorAccessState::Expired,
124 OperatorAccessState::Approved if now >= self.granted_until_unix_ms => OperatorAccessState::Expired,
125 state => state,
126 }
127 }
128
129 pub fn as_at(mut self, now: i64) -> OperatorAccess {
131 self.state = self.state_at(now);
132 self
133 }
134}
135
136#[derive(Clone, Copy, PartialEq, Eq, Debug)]
138pub enum Shield {
139 Open,
141 ReadOnly,
143 Closed,
145}
146
147fn key(account: &str, operator: &str) -> String {
148 format!("{account}\u{1f}{operator}")
149}
150
151fn refused(reason: impl Into<String>) -> Error {
152 Error::InvalidInput {
153 what: "operator access",
154 reason: reason.into(),
155 }
156}
157
158const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
160
161fn code_of(bytes: &[u8; 16]) -> String {
162 let letter = |b: u8| LETTERS[usize::from(b) % LETTERS.len()] as char;
163 let digit = |b: u8| char::from(b'0' + b % 10);
164 format!(
165 "{}{}{}-{}{}{}",
166 letter(bytes[0]),
167 letter(bytes[1]),
168 letter(bytes[2]),
169 digit(bytes[3]),
170 digit(bytes[4]),
171 digit(bytes[5])
172 )
173}
174
175impl AuthTable {
176 pub(super) fn account_in(doc: &AuthDoc, namespace: &str) -> Result<Option<String>> {
179 let account = namespace.split('/').next().unwrap_or("");
180 if account.is_empty() || Self::is_box_identity(doc, account) {
181 return Ok(None);
182 }
183 if !doc.clans.is_empty() && doc.clans.contains_key(account)? {
188 return Ok(Some(account.to_owned()));
189 }
190 match doc.principals.get(account)? {
191 Some(owner) if owner.kind == PrincipalKind::User && !doc.employers.contains_key(account)? => {
192 Ok(Some(account.to_owned()))
193 }
194 _ => Ok(None),
195 }
196 }
197
198 pub(super) fn is_box_identity(doc: &AuthDoc, principal_id: &str) -> bool {
204 doc.bootstrap.as_ref().is_some_and(|b| !b.principal_id.is_empty() && b.principal_id == principal_id)
205 }
206
207 pub(super) fn shield_in(doc: &AuthDoc, principal_id: &str, namespace: &str, now: i64) -> Result<Shield> {
209 let Some(account) = Self::account_in(doc, namespace)? else {
210 return Ok(Shield::Open);
211 };
212 if doc.principals.get(principal_id)?.is_some_and(|p| p.kind == PrincipalKind::Machine)
219 || Self::is_box_identity(doc, principal_id)
220 {
221 return Ok(Shield::Open);
222 }
223 if account == principal_id || doc.employers.get(principal_id)?.as_deref() == Some(account.as_str()) {
224 return Ok(Shield::Open);
225 }
226 if Self::clan_member(doc, principal_id, &account)? {
229 return Ok(Shield::Open);
230 }
231 match doc.operator_access.get(&key(&account, principal_id))? {
232 Some(row) if row.is_live_at(now) => Ok(Shield::ReadOnly),
233 _ => Ok(Shield::Closed),
234 }
235 }
236
237 pub(super) fn best_under(
239 grants: impl Iterator<Item = crate::model::Grant>,
240 shield: impl Fn() -> Result<Shield>,
241 ) -> Result<Option<Level>> {
242 let mut best: Option<Level> = None;
243 let mut server: Option<Level> = None;
244 for grant in grants {
245 if grant.scope == Scope::Server {
246 server = server.max(Some(grant.level));
247 } else {
248 best = best.max(Some(grant.level));
249 }
250 }
251 if let Some(level) = server {
252 let capped = match shield()? {
253 Shield::Open => Some(level),
254 Shield::ReadOnly => Some(level.min(Level::Read)),
255 Shield::Closed => None,
256 };
257 best = best.max(capped);
258 }
259 Ok(best)
260 }
261
262 pub fn shielded(&self, principal_id: &str, namespace: &str) -> Result<bool> {
265 self.shielded_at(principal_id, namespace, crate::model::now_ms())
266 }
267
268 pub fn shielded_at(&self, principal_id: &str, namespace: &str, now: i64) -> Result<bool> {
270 let doc = self.doc.load()?;
271 Ok(Self::shield_in(&doc, principal_id, namespace, now)? == Shield::Closed)
272 }
273
274 pub fn account_of_namespace(&self, namespace: &str) -> Result<Option<String>> {
276 let doc = self.doc.load()?;
277 Self::account_in(&doc, namespace)
278 }
279
280 pub fn account_of_principal(&self, principal_id: &str) -> Result<Option<String>> {
283 let doc = self.doc.load()?;
284 if let Some(employer) = doc.employers.get(principal_id)? {
285 return Ok(Some(employer));
286 }
287 Self::account_in(&doc, principal_id).map(|a| a.filter(|a| a == principal_id))
288 }
289
290 fn clan_member(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
293 if doc.clans.is_empty() {
294 return Ok(false);
295 }
296 Ok(doc.clans.get(account)?.is_some_and(|clan| {
297 matches!(clan.role_of(principal_id), Some(super::clan_table::ClanRole::Admin | super::clan_table::ClanRole::Member))
298 }))
299 }
300
301 fn admin_in(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
302 if !doc.clans.is_empty() {
305 if let Some(clan) = doc.clans.get(account)? {
306 return Ok(clan.role_of(principal_id) == Some(super::clan_table::ClanRole::Admin));
307 }
308 }
309 if principal_id == account {
310 return doc.principals.contains_key(account);
311 }
312 if !doc.principals.contains_key(principal_id)? {
313 return Ok(false);
314 }
315 Ok(Self::holder_grants(doc, principal_id)?
316 .into_iter()
317 .any(|g| g.level == Level::Admin && g.scope == Scope::Namespace(account.to_owned())))
318 }
319
320 pub fn role_in_account(&self, actor: &str, account: &str) -> Result<&'static str> {
325 if actor.is_empty() {
326 return Ok("system");
327 }
328 let doc = self.doc.load()?;
329 if Self::admin_in(&doc, actor, account)? {
330 return Ok("admin");
331 }
332 if doc.employers.get(actor)?.as_deref() == Some(account) || Self::clan_member(&doc, actor, account)? {
333 return Ok("member");
334 }
335 if doc.principals.get(actor)?.is_some_and(|p| p.kind == PrincipalKind::Machine) {
336 return Ok("machine");
337 }
338 let server = Self::holder_grants(&doc, actor)?.into_iter().any(|g| g.scope == Scope::Server);
339 Ok(if server { "operator" } else { "outsider" })
340 }
341
342 pub fn is_account_admin(&self, principal_id: &str, account: &str) -> Result<bool> {
346 let doc = self.doc.load()?;
347 Self::admin_in(&doc, principal_id, account)
348 }
349
350 pub fn request_operator_access(
353 &self,
354 account: &str,
355 operator: &str,
356 reason: &str,
357 minutes: u32,
358 now: i64,
359 ) -> Result<OperatorAccess> {
360 let reason = reason.trim();
361 if reason.is_empty() {
362 return Err(refused("a reason is required: the account admin reads it before deciding"));
363 }
364 if reason.chars().count() > MAX_REASON_CHARS {
365 return Err(refused(format!("the reason is longer than {MAX_REASON_CHARS} characters")));
366 }
367 let minutes = if minutes == 0 { OPERATOR_ACCESS_DEFAULT_MINUTES } else { minutes };
368 if minutes > OPERATOR_ACCESS_MAX_MINUTES {
369 return Err(refused(format!(
370 "a look lasts at most {OPERATOR_ACCESS_MAX_MINUTES} minutes (a week); ask for less"
371 )));
372 }
373 self.doc.update(|doc| {
374 if Self::account_in(doc, account)?.as_deref() != Some(account) {
375 return Err(refused(format!("{account:?} is not an account: no person owns it")));
376 }
377 if account == operator
378 || doc.employers.get(operator)?.as_deref() == Some(account)
379 || Self::clan_member(doc, operator, account)?
380 {
381 return Err(refused(format!("{operator} is inside {account} already; there is nothing to ask")));
382 }
383 if !doc.principals.contains_key(operator)? {
384 return Err(Error::NoSuchPrincipal(operator.to_owned()));
385 }
386 let k = key(account, operator);
387 if let Some(row) = doc.operator_access.get(&k)? {
388 if row.state_at(now) == OperatorAccessState::Pending {
389 return Ok(row);
390 }
391 }
392 let mut bytes = [0u8; 16];
393 {
394 use rand::Rng as _;
395 crate::os_rng().fill_bytes(&mut bytes);
396 }
397 let row = OperatorAccess {
398 id: hex::encode(bytes),
399 code: code_of(&bytes),
400 account: account.to_owned(),
401 operator: operator.to_owned(),
402 reason: reason.to_owned(),
403 minutes,
404 requested_at_unix_ms: now,
405 request_expires_at_unix_ms: now + OPERATOR_REQUEST_TTL_MS,
406 state: OperatorAccessState::Pending,
407 granted_until_unix_ms: 0,
408 decided_by: String::new(),
409 decided_at_unix_ms: 0,
410 };
411 doc.operator_access.insert(k, row.clone())?;
412 Ok(row)
413 })
414 }
415
416 fn find(doc: &AuthDoc, id: &str) -> Result<Option<(String, OperatorAccess)>> {
417 Ok(doc.operator_access.entries()?.into_iter().find(|(_, row)| row.id == id))
418 }
419
420 pub fn decide_operator_access(
424 &self,
425 id: &str,
426 approver: &str,
427 approve: bool,
428 minutes: Option<u32>,
429 now: i64,
430 ) -> Result<OperatorAccess> {
431 self.decide_operator_access_stepped(id, approver, approve, minutes, None, now)
432 }
433
434 pub fn decide_operator_access_stepped(
440 &self,
441 id: &str,
442 approver: &str,
443 approve: bool,
444 minutes: Option<u32>,
445 step_up: Option<(&str, u32)>,
446 now: i64,
447 ) -> Result<OperatorAccess> {
448 if let Some(m) = minutes {
449 if m == 0 || m > OPERATOR_ACCESS_MAX_MINUTES {
450 return Err(refused(format!("a look lasts 1 to {OPERATOR_ACCESS_MAX_MINUTES} minutes")));
451 }
452 }
453 self.doc.update(|doc| {
454 let Some((k, mut row)) = Self::find(doc, id)? else {
455 return Err(refused(format!("no operator access request {id}")));
456 };
457 if !Self::admin_in(doc, approver, &row.account)? {
458 return Err(refused(format!(
459 "{approver} is not an admin of account {}: only the account's admin decides an operator's look",
460 row.account
461 )));
462 }
463 match row.state_at(now) {
464 OperatorAccessState::Pending => {}
465 other => return Err(refused(format!("request {id} is {}: it cannot be decided now", other.as_str()))),
466 }
467 if let Some((credential_id, presented)) = step_up {
468 let mut credential = doc
469 .credentials
470 .get(credential_id)?
471 .ok_or_else(|| Error::NoSuchCredential(credential_id.to_owned()))?;
472 if credential.principal_id != approver {
473 return Err(refused(format!("the step-up's passkey is not {approver}'s")));
474 }
475 if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
476 return Err(Error::CounterRollback {
477 id: credential_id.to_owned(),
478 stored: credential.sign_count,
479 presented,
480 });
481 }
482 if presented != 0 {
483 credential.sign_count = presented;
484 }
485 credential.last_used = Some(now);
486 doc.credentials.insert(credential_id.to_owned(), credential)?;
487 }
488 row.decided_by = approver.to_owned();
489 row.decided_at_unix_ms = now;
490 if approve {
491 let minutes = minutes.unwrap_or(row.minutes);
492 row.minutes = minutes;
493 row.state = OperatorAccessState::Approved;
494 row.granted_until_unix_ms = now + i64::from(minutes) * 60_000;
495 } else {
496 row.state = OperatorAccessState::Refused;
497 }
498 doc.operator_access.insert(k, row.clone())?;
499 Ok(row)
500 })
501 }
502
503 pub fn revoke_operator_access(&self, id: &str, by: &str, now: i64) -> Result<OperatorAccess> {
506 self.doc.update(|doc| {
507 let Some((k, mut row)) = Self::find(doc, id)? else {
508 return Err(refused(format!("no operator access request {id}")));
509 };
510 if by != row.operator && !Self::admin_in(doc, by, &row.account)? {
511 return Err(refused(format!(
512 "{by} is not an admin of account {}: only the account's admin (or the operator that asked) ends a look",
513 row.account
514 )));
515 }
516 match row.state_at(now) {
517 OperatorAccessState::Pending | OperatorAccessState::Approved => {}
518 other => return Err(refused(format!("request {id} is {} already", other.as_str()))),
519 }
520 row.state = OperatorAccessState::Revoked;
521 if row.granted_until_unix_ms > now {
522 row.granted_until_unix_ms = now;
523 }
524 row.decided_by = by.to_owned();
525 row.decided_at_unix_ms = now;
526 doc.operator_access.insert(k, row.clone())?;
527 Ok(row)
528 })
529 }
530
531 pub fn operator_access(&self, account: Option<&str>) -> Result<Vec<OperatorAccess>> {
534 self.operator_access_at(account, crate::model::now_ms())
535 }
536
537 pub fn operator_access_at(&self, account: Option<&str>, now: i64) -> Result<Vec<OperatorAccess>> {
539 let mut rows: Vec<OperatorAccess> = self
540 .doc
541 .load()?
542 .operator_access
543 .values()?
544 .into_iter()
545 .filter(|row| account.is_none_or(|a| row.account == a))
546 .map(|row| row.as_at(now))
547 .collect();
548 rows.sort_by_key(|row| std::cmp::Reverse(row.requested_at_unix_ms));
549 Ok(rows)
550 }
551
552 pub fn operator_access_by_id(&self, id: &str) -> Result<Option<OperatorAccess>> {
554 let now = crate::model::now_ms();
555 let doc = self.doc.load()?;
556 Ok(Self::find(&doc, id)?.map(|(_, row)| row.as_at(now)))
557 }
558}