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 => {
124 OperatorAccessState::Expired
125 }
126 OperatorAccessState::Approved if now >= self.granted_until_unix_ms => {
127 OperatorAccessState::Expired
128 }
129 state => state,
130 }
131 }
132
133 pub fn as_at(mut self, now: i64) -> OperatorAccess {
135 self.state = self.state_at(now);
136 self
137 }
138}
139
140#[derive(Clone, Copy, PartialEq, Eq, Debug)]
142pub enum Shield {
143 Open,
145 ReadOnly,
147 Closed,
149}
150
151fn key(account: &str, operator: &str) -> String {
152 format!("{account}\u{1f}{operator}")
153}
154
155fn refused(reason: impl Into<String>) -> Error {
156 Error::InvalidInput {
157 what: "operator access",
158 reason: reason.into(),
159 }
160}
161
162const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
164
165fn code_of(bytes: &[u8; 16]) -> String {
166 let letter = |b: u8| LETTERS[usize::from(b) % LETTERS.len()] as char;
167 let digit = |b: u8| char::from(b'0' + b % 10);
168 format!(
169 "{}{}{}-{}{}{}",
170 letter(bytes[0]),
171 letter(bytes[1]),
172 letter(bytes[2]),
173 digit(bytes[3]),
174 digit(bytes[4]),
175 digit(bytes[5])
176 )
177}
178
179impl AuthTable {
180 pub(super) fn account_in(doc: &AuthDoc, namespace: &str) -> Result<Option<String>> {
183 let account = namespace.split('/').next().unwrap_or("");
184 if account.is_empty() || Self::is_box_identity(doc, account) {
185 return Ok(None);
186 }
187 if !doc.clans.is_empty() && doc.clans.contains_key(account)? {
192 return Ok(Some(account.to_owned()));
193 }
194 match doc.principals.get(account)? {
195 Some(owner)
196 if owner.kind == PrincipalKind::User && !doc.employers.contains_key(account)? =>
197 {
198 Ok(Some(account.to_owned()))
199 }
200 _ => Ok(None),
201 }
202 }
203
204 pub(super) fn is_box_identity(doc: &AuthDoc, principal_id: &str) -> bool {
210 doc.bootstrap
211 .as_ref()
212 .is_some_and(|b| !b.principal_id.is_empty() && b.principal_id == principal_id)
213 }
214
215 pub(super) fn shield_in(
217 doc: &AuthDoc,
218 principal_id: &str,
219 namespace: &str,
220 now: i64,
221 ) -> Result<Shield> {
222 let Some(account) = Self::account_in(doc, namespace)? else {
223 return Ok(Shield::Open);
224 };
225 if doc
232 .principals
233 .get(principal_id)?
234 .is_some_and(|p| p.kind == PrincipalKind::Machine)
235 || Self::is_box_identity(doc, principal_id)
236 {
237 return Ok(Shield::Open);
238 }
239 if account == principal_id
240 || doc.employers.get(principal_id)?.as_deref() == Some(account.as_str())
241 {
242 return Ok(Shield::Open);
243 }
244 if Self::clan_member(doc, principal_id, &account)? {
247 return Ok(Shield::Open);
248 }
249 match doc.operator_access.get(&key(&account, principal_id))? {
250 Some(row) if row.is_live_at(now) => Ok(Shield::ReadOnly),
251 _ => Ok(Shield::Closed),
252 }
253 }
254
255 pub(super) fn best_under(
257 grants: impl Iterator<Item = crate::model::Grant>,
258 shield: impl Fn() -> Result<Shield>,
259 ) -> Result<Option<Level>> {
260 let mut best: Option<Level> = None;
261 let mut server: Option<Level> = None;
262 for grant in grants {
263 if grant.scope == Scope::Server {
264 server = server.max(Some(grant.level));
265 } else {
266 best = best.max(Some(grant.level));
267 }
268 }
269 if let Some(level) = server {
270 let capped = match shield()? {
271 Shield::Open => Some(level),
272 Shield::ReadOnly => Some(level.min(Level::Read)),
273 Shield::Closed => None,
274 };
275 best = best.max(capped);
276 }
277 Ok(best)
278 }
279
280 pub fn shielded(&self, principal_id: &str, namespace: &str) -> Result<bool> {
283 self.shielded_at(principal_id, namespace, crate::model::now_ms())
284 }
285
286 pub fn shielded_at(&self, principal_id: &str, namespace: &str, now: i64) -> Result<bool> {
288 let doc = self.doc.load()?;
289 Ok(Self::shield_in(&doc, principal_id, namespace, now)? == Shield::Closed)
290 }
291
292 pub fn account_of_namespace(&self, namespace: &str) -> Result<Option<String>> {
294 let doc = self.doc.load()?;
295 Self::account_in(&doc, namespace)
296 }
297
298 pub fn account_of_principal(&self, principal_id: &str) -> Result<Option<String>> {
301 let doc = self.doc.load()?;
302 if let Some(employer) = doc.employers.get(principal_id)? {
303 return Ok(Some(employer));
304 }
305 Self::account_in(&doc, principal_id).map(|a| a.filter(|a| a == principal_id))
306 }
307
308 fn clan_member(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
311 if doc.clans.is_empty() {
312 return Ok(false);
313 }
314 Ok(doc.clans.get(account)?.is_some_and(|clan| {
315 matches!(
316 clan.role_of(principal_id),
317 Some(super::clan_table::ClanRole::Admin | super::clan_table::ClanRole::Member)
318 )
319 }))
320 }
321
322 fn admin_in(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
323 if !doc.clans.is_empty() {
326 if let Some(clan) = doc.clans.get(account)? {
327 return Ok(clan.role_of(principal_id) == Some(super::clan_table::ClanRole::Admin));
328 }
329 }
330 if principal_id == account {
331 return doc.principals.contains_key(account);
332 }
333 if !doc.principals.contains_key(principal_id)? {
334 return Ok(false);
335 }
336 Ok(Self::holder_grants(doc, principal_id)?
337 .into_iter()
338 .any(|g| g.level == Level::Admin && g.scope == Scope::Namespace(account.to_owned())))
339 }
340
341 pub fn role_in_account(&self, actor: &str, account: &str) -> Result<&'static str> {
346 if actor.is_empty() {
347 return Ok("system");
348 }
349 let doc = self.doc.load()?;
350 if Self::admin_in(&doc, actor, account)? {
351 return Ok("admin");
352 }
353 if doc.employers.get(actor)?.as_deref() == Some(account)
354 || Self::clan_member(&doc, actor, account)?
355 {
356 return Ok("member");
357 }
358 if doc
359 .principals
360 .get(actor)?
361 .is_some_and(|p| p.kind == PrincipalKind::Machine)
362 {
363 return Ok("machine");
364 }
365 let server = Self::holder_grants(&doc, actor)?
366 .into_iter()
367 .any(|g| g.scope == Scope::Server);
368 Ok(if server { "operator" } else { "outsider" })
369 }
370
371 pub fn is_account_admin(&self, principal_id: &str, account: &str) -> Result<bool> {
375 let doc = self.doc.load()?;
376 Self::admin_in(&doc, principal_id, account)
377 }
378
379 pub fn request_operator_access(
382 &self,
383 account: &str,
384 operator: &str,
385 reason: &str,
386 minutes: u32,
387 now: i64,
388 ) -> Result<OperatorAccess> {
389 let reason = reason.trim();
390 if reason.is_empty() {
391 return Err(refused(
392 "a reason is required: the account admin reads it before deciding",
393 ));
394 }
395 if reason.chars().count() > MAX_REASON_CHARS {
396 return Err(refused(format!(
397 "the reason is longer than {MAX_REASON_CHARS} characters"
398 )));
399 }
400 let minutes = if minutes == 0 {
401 OPERATOR_ACCESS_DEFAULT_MINUTES
402 } else {
403 minutes
404 };
405 if minutes > OPERATOR_ACCESS_MAX_MINUTES {
406 return Err(refused(format!(
407 "a look lasts at most {OPERATOR_ACCESS_MAX_MINUTES} minutes (a week); ask for less"
408 )));
409 }
410 self.doc.update(|doc| {
411 if Self::account_in(doc, account)?.as_deref() != Some(account) {
412 return Err(refused(format!(
413 "{account:?} is not an account: no person owns it"
414 )));
415 }
416 if account == operator
417 || doc.employers.get(operator)?.as_deref() == Some(account)
418 || Self::clan_member(doc, operator, account)?
419 {
420 return Err(refused(format!(
421 "{operator} is inside {account} already; there is nothing to ask"
422 )));
423 }
424 if !doc.principals.contains_key(operator)? {
425 return Err(Error::NoSuchPrincipal(operator.to_owned()));
426 }
427 let k = key(account, operator);
428 if let Some(row) = doc.operator_access.get(&k)? {
429 if row.state_at(now) == OperatorAccessState::Pending {
430 return Ok(row);
431 }
432 }
433 let mut bytes = [0u8; 16];
434 {
435 use rand::Rng as _;
436 crate::os_rng().fill_bytes(&mut bytes);
437 }
438 let row = OperatorAccess {
439 id: hex::encode(bytes),
440 code: code_of(&bytes),
441 account: account.to_owned(),
442 operator: operator.to_owned(),
443 reason: reason.to_owned(),
444 minutes,
445 requested_at_unix_ms: now,
446 request_expires_at_unix_ms: now + OPERATOR_REQUEST_TTL_MS,
447 state: OperatorAccessState::Pending,
448 granted_until_unix_ms: 0,
449 decided_by: String::new(),
450 decided_at_unix_ms: 0,
451 };
452 doc.operator_access.insert(k, row.clone())?;
453 Ok(row)
454 })
455 }
456
457 fn find(doc: &AuthDoc, id: &str) -> Result<Option<(String, OperatorAccess)>> {
458 Ok(doc
459 .operator_access
460 .entries()?
461 .into_iter()
462 .find(|(_, row)| row.id == id))
463 }
464
465 pub fn decide_operator_access(
469 &self,
470 id: &str,
471 approver: &str,
472 approve: bool,
473 minutes: Option<u32>,
474 now: i64,
475 ) -> Result<OperatorAccess> {
476 self.decide_operator_access_stepped(id, approver, approve, minutes, None, now)
477 }
478
479 pub fn decide_operator_access_stepped(
485 &self,
486 id: &str,
487 approver: &str,
488 approve: bool,
489 minutes: Option<u32>,
490 step_up: Option<(&str, u32)>,
491 now: i64,
492 ) -> Result<OperatorAccess> {
493 if let Some(m) = minutes {
494 if m == 0 || m > OPERATOR_ACCESS_MAX_MINUTES {
495 return Err(refused(format!(
496 "a look lasts 1 to {OPERATOR_ACCESS_MAX_MINUTES} minutes"
497 )));
498 }
499 }
500 self.doc.update(|doc| {
501 let Some((k, mut row)) = Self::find(doc, id)? else {
502 return Err(refused(format!("no operator access request {id}")));
503 };
504 if !Self::admin_in(doc, approver, &row.account)? {
505 return Err(refused(format!(
506 "{approver} is not an admin of account {}: only the account's admin decides an operator's look",
507 row.account
508 )));
509 }
510 match row.state_at(now) {
511 OperatorAccessState::Pending => {}
512 other => return Err(refused(format!("request {id} is {}: it cannot be decided now", other.as_str()))),
513 }
514 if let Some((credential_id, presented)) = step_up {
515 let mut credential = doc
516 .credentials
517 .get(credential_id)?
518 .ok_or_else(|| Error::NoSuchCredential(credential_id.to_owned()))?;
519 if credential.principal_id != approver {
520 return Err(refused(format!("the step-up's passkey is not {approver}'s")));
521 }
522 if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
523 return Err(Error::CounterRollback {
524 id: credential_id.to_owned(),
525 stored: credential.sign_count,
526 presented,
527 });
528 }
529 if presented != 0 {
530 credential.sign_count = presented;
531 }
532 credential.last_used = Some(now);
533 doc.credentials.insert(credential_id.to_owned(), credential)?;
534 }
535 row.decided_by = approver.to_owned();
536 row.decided_at_unix_ms = now;
537 if approve {
538 let minutes = minutes.unwrap_or(row.minutes);
539 row.minutes = minutes;
540 row.state = OperatorAccessState::Approved;
541 row.granted_until_unix_ms = now + i64::from(minutes) * 60_000;
542 } else {
543 row.state = OperatorAccessState::Refused;
544 }
545 doc.operator_access.insert(k, row.clone())?;
546 Ok(row)
547 })
548 }
549
550 pub fn revoke_operator_access(&self, id: &str, by: &str, now: i64) -> Result<OperatorAccess> {
553 self.doc.update(|doc| {
554 let Some((k, mut row)) = Self::find(doc, id)? else {
555 return Err(refused(format!("no operator access request {id}")));
556 };
557 if by != row.operator && !Self::admin_in(doc, by, &row.account)? {
558 return Err(refused(format!(
559 "{by} is not an admin of account {}: only the account's admin (or the operator that asked) ends a look",
560 row.account
561 )));
562 }
563 match row.state_at(now) {
564 OperatorAccessState::Pending | OperatorAccessState::Approved => {}
565 other => return Err(refused(format!("request {id} is {} already", other.as_str()))),
566 }
567 row.state = OperatorAccessState::Revoked;
568 if row.granted_until_unix_ms > now {
569 row.granted_until_unix_ms = now;
570 }
571 row.decided_by = by.to_owned();
572 row.decided_at_unix_ms = now;
573 doc.operator_access.insert(k, row.clone())?;
574 Ok(row)
575 })
576 }
577
578 pub fn operator_access(&self, account: Option<&str>) -> Result<Vec<OperatorAccess>> {
581 self.operator_access_at(account, crate::model::now_ms())
582 }
583
584 pub fn operator_access_at(
586 &self,
587 account: Option<&str>,
588 now: i64,
589 ) -> Result<Vec<OperatorAccess>> {
590 let mut rows: Vec<OperatorAccess> = self
591 .doc
592 .load()?
593 .operator_access
594 .values()?
595 .into_iter()
596 .filter(|row| account.is_none_or(|a| row.account == a))
597 .map(|row| row.as_at(now))
598 .collect();
599 rows.sort_by_key(|row| std::cmp::Reverse(row.requested_at_unix_ms));
600 Ok(rows)
601 }
602
603 pub fn operator_access_by_id(&self, id: &str) -> Result<Option<OperatorAccess>> {
605 let now = crate::model::now_ms();
606 let doc = self.doc.load()?;
607 Ok(Self::find(&doc, id)?.map(|(_, row)| row.as_at(now)))
608 }
609}