use super::{AuthDoc, AuthTable};
use crate::error::{Error, Result};
use crate::model::{Level, PrincipalKind, Scope};
pub const OPERATOR_REQUEST_TTL_MS: i64 = 3 * 24 * 3_600_000;
pub const OPERATOR_ACCESS_MAX_MINUTES: u32 = 7 * 24 * 60;
pub const OPERATOR_ACCESS_DEFAULT_MINUTES: u32 = 60;
const MAX_REASON_CHARS: usize = 500;
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum OperatorAccessState {
Pending,
Approved,
Refused,
Revoked,
Expired,
}
impl OperatorAccessState {
pub fn as_str(self) -> &'static str {
match self {
OperatorAccessState::Pending => "pending",
OperatorAccessState::Approved => "approved",
OperatorAccessState::Refused => "refused",
OperatorAccessState::Revoked => "revoked",
OperatorAccessState::Expired => "expired",
}
}
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperatorAccess {
pub id: String,
pub code: String,
pub account: String,
pub operator: String,
pub reason: String,
pub minutes: u32,
pub requested_at_unix_ms: i64,
pub request_expires_at_unix_ms: i64,
pub state: OperatorAccessState,
#[serde(default)]
pub granted_until_unix_ms: i64,
#[serde(default)]
pub decided_by: String,
#[serde(default)]
pub decided_at_unix_ms: i64,
}
impl OperatorAccess {
pub fn is_live_at(&self, now: i64) -> bool {
self.state == OperatorAccessState::Approved && now < self.granted_until_unix_ms
}
pub fn state_at(&self, now: i64) -> OperatorAccessState {
match self.state {
OperatorAccessState::Pending if now >= self.request_expires_at_unix_ms => {
OperatorAccessState::Expired
}
OperatorAccessState::Approved if now >= self.granted_until_unix_ms => {
OperatorAccessState::Expired
}
state => state,
}
}
pub fn as_at(mut self, now: i64) -> OperatorAccess {
self.state = self.state_at(now);
self
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Shield {
Open,
ReadOnly,
Closed,
}
fn key(account: &str, operator: &str) -> String {
format!("{account}\u{1f}{operator}")
}
fn refused(reason: impl Into<String>) -> Error {
Error::InvalidInput {
what: "operator access",
reason: reason.into(),
}
}
const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
fn code_of(bytes: &[u8; 16]) -> String {
let letter = |b: u8| LETTERS[usize::from(b) % LETTERS.len()] as char;
let digit = |b: u8| char::from(b'0' + b % 10);
format!(
"{}{}{}-{}{}{}",
letter(bytes[0]),
letter(bytes[1]),
letter(bytes[2]),
digit(bytes[3]),
digit(bytes[4]),
digit(bytes[5])
)
}
impl AuthTable {
pub(super) fn account_in(doc: &AuthDoc, namespace: &str) -> Result<Option<String>> {
let account = namespace.split('/').next().unwrap_or("");
if account.is_empty() || Self::is_box_identity(doc, account) {
return Ok(None);
}
if !doc.clans.is_empty() && doc.clans.contains_key(account)? {
return Ok(Some(account.to_owned()));
}
match doc.principals.get(account)? {
Some(owner)
if owner.kind == PrincipalKind::User && !doc.employers.contains_key(account)? =>
{
Ok(Some(account.to_owned()))
}
_ => Ok(None),
}
}
pub(super) fn is_box_identity(doc: &AuthDoc, principal_id: &str) -> bool {
doc.bootstrap
.as_ref()
.is_some_and(|b| !b.principal_id.is_empty() && b.principal_id == principal_id)
}
pub(super) fn shield_in(
doc: &AuthDoc,
principal_id: &str,
namespace: &str,
now: i64,
) -> Result<Shield> {
let Some(account) = Self::account_in(doc, namespace)? else {
return Ok(Shield::Open);
};
if doc
.principals
.get(principal_id)?
.is_some_and(|p| p.kind == PrincipalKind::Machine)
|| Self::is_box_identity(doc, principal_id)
{
return Ok(Shield::Open);
}
if account == principal_id
|| doc.employers.get(principal_id)?.as_deref() == Some(account.as_str())
{
return Ok(Shield::Open);
}
if Self::clan_member(doc, principal_id, &account)? {
return Ok(Shield::Open);
}
match doc.operator_access.get(&key(&account, principal_id))? {
Some(row) if row.is_live_at(now) => Ok(Shield::ReadOnly),
_ => Ok(Shield::Closed),
}
}
pub(super) fn best_under(
grants: impl Iterator<Item = crate::model::Grant>,
shield: impl Fn() -> Result<Shield>,
) -> Result<Option<Level>> {
let mut best: Option<Level> = None;
let mut server: Option<Level> = None;
for grant in grants {
if grant.scope == Scope::Server {
server = server.max(Some(grant.level));
} else {
best = best.max(Some(grant.level));
}
}
if let Some(level) = server {
let capped = match shield()? {
Shield::Open => Some(level),
Shield::ReadOnly => Some(level.min(Level::Read)),
Shield::Closed => None,
};
best = best.max(capped);
}
Ok(best)
}
pub fn shielded(&self, principal_id: &str, namespace: &str) -> Result<bool> {
self.shielded_at(principal_id, namespace, crate::model::now_ms())
}
pub fn shielded_at(&self, principal_id: &str, namespace: &str, now: i64) -> Result<bool> {
let doc = self.doc.load()?;
Ok(Self::shield_in(&doc, principal_id, namespace, now)? == Shield::Closed)
}
pub fn account_of_namespace(&self, namespace: &str) -> Result<Option<String>> {
let doc = self.doc.load()?;
Self::account_in(&doc, namespace)
}
pub fn account_of_principal(&self, principal_id: &str) -> Result<Option<String>> {
let doc = self.doc.load()?;
if let Some(employer) = doc.employers.get(principal_id)? {
return Ok(Some(employer));
}
Self::account_in(&doc, principal_id).map(|a| a.filter(|a| a == principal_id))
}
fn clan_member(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
if doc.clans.is_empty() {
return Ok(false);
}
Ok(doc.clans.get(account)?.is_some_and(|clan| {
matches!(
clan.role_of(principal_id),
Some(super::clan_table::ClanRole::Admin | super::clan_table::ClanRole::Member)
)
}))
}
fn admin_in(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
if !doc.clans.is_empty() {
if let Some(clan) = doc.clans.get(account)? {
return Ok(clan.role_of(principal_id) == Some(super::clan_table::ClanRole::Admin));
}
}
if principal_id == account {
return doc.principals.contains_key(account);
}
if !doc.principals.contains_key(principal_id)? {
return Ok(false);
}
Ok(Self::holder_grants(doc, principal_id)?
.into_iter()
.any(|g| g.level == Level::Admin && g.scope == Scope::Namespace(account.to_owned())))
}
pub fn role_in_account(&self, actor: &str, account: &str) -> Result<&'static str> {
if actor.is_empty() {
return Ok("system");
}
let doc = self.doc.load()?;
if Self::admin_in(&doc, actor, account)? {
return Ok("admin");
}
if doc.employers.get(actor)?.as_deref() == Some(account)
|| Self::clan_member(&doc, actor, account)?
{
return Ok("member");
}
if doc
.principals
.get(actor)?
.is_some_and(|p| p.kind == PrincipalKind::Machine)
{
return Ok("machine");
}
let server = Self::holder_grants(&doc, actor)?
.into_iter()
.any(|g| g.scope == Scope::Server);
Ok(if server { "operator" } else { "outsider" })
}
pub fn is_account_admin(&self, principal_id: &str, account: &str) -> Result<bool> {
let doc = self.doc.load()?;
Self::admin_in(&doc, principal_id, account)
}
pub fn request_operator_access(
&self,
account: &str,
operator: &str,
reason: &str,
minutes: u32,
now: i64,
) -> Result<OperatorAccess> {
let reason = reason.trim();
if reason.is_empty() {
return Err(refused(
"a reason is required: the account admin reads it before deciding",
));
}
if reason.chars().count() > MAX_REASON_CHARS {
return Err(refused(format!(
"the reason is longer than {MAX_REASON_CHARS} characters"
)));
}
let minutes = if minutes == 0 {
OPERATOR_ACCESS_DEFAULT_MINUTES
} else {
minutes
};
if minutes > OPERATOR_ACCESS_MAX_MINUTES {
return Err(refused(format!(
"a look lasts at most {OPERATOR_ACCESS_MAX_MINUTES} minutes (a week); ask for less"
)));
}
self.doc.update(|doc| {
if Self::account_in(doc, account)?.as_deref() != Some(account) {
return Err(refused(format!(
"{account:?} is not an account: no person owns it"
)));
}
if account == operator
|| doc.employers.get(operator)?.as_deref() == Some(account)
|| Self::clan_member(doc, operator, account)?
{
return Err(refused(format!(
"{operator} is inside {account} already; there is nothing to ask"
)));
}
if !doc.principals.contains_key(operator)? {
return Err(Error::NoSuchPrincipal(operator.to_owned()));
}
let k = key(account, operator);
if let Some(row) = doc.operator_access.get(&k)? {
if row.state_at(now) == OperatorAccessState::Pending {
return Ok(row);
}
}
let mut bytes = [0u8; 16];
{
use rand::Rng as _;
crate::os_rng().fill_bytes(&mut bytes);
}
let row = OperatorAccess {
id: hex::encode(bytes),
code: code_of(&bytes),
account: account.to_owned(),
operator: operator.to_owned(),
reason: reason.to_owned(),
minutes,
requested_at_unix_ms: now,
request_expires_at_unix_ms: now + OPERATOR_REQUEST_TTL_MS,
state: OperatorAccessState::Pending,
granted_until_unix_ms: 0,
decided_by: String::new(),
decided_at_unix_ms: 0,
};
doc.operator_access.insert(k, row.clone())?;
Ok(row)
})
}
fn find(doc: &AuthDoc, id: &str) -> Result<Option<(String, OperatorAccess)>> {
Ok(doc
.operator_access
.entries()?
.into_iter()
.find(|(_, row)| row.id == id))
}
pub fn decide_operator_access(
&self,
id: &str,
approver: &str,
approve: bool,
minutes: Option<u32>,
now: i64,
) -> Result<OperatorAccess> {
self.decide_operator_access_stepped(id, approver, approve, minutes, None, now)
}
pub fn decide_operator_access_stepped(
&self,
id: &str,
approver: &str,
approve: bool,
minutes: Option<u32>,
step_up: Option<(&str, u32)>,
now: i64,
) -> Result<OperatorAccess> {
if let Some(m) = minutes {
if m == 0 || m > OPERATOR_ACCESS_MAX_MINUTES {
return Err(refused(format!(
"a look lasts 1 to {OPERATOR_ACCESS_MAX_MINUTES} minutes"
)));
}
}
self.doc.update(|doc| {
let Some((k, mut row)) = Self::find(doc, id)? else {
return Err(refused(format!("no operator access request {id}")));
};
if !Self::admin_in(doc, approver, &row.account)? {
return Err(refused(format!(
"{approver} is not an admin of account {}: only the account's admin decides an operator's look",
row.account
)));
}
match row.state_at(now) {
OperatorAccessState::Pending => {}
other => return Err(refused(format!("request {id} is {}: it cannot be decided now", other.as_str()))),
}
if let Some((credential_id, presented)) = step_up {
let mut credential = doc
.credentials
.get(credential_id)?
.ok_or_else(|| Error::NoSuchCredential(credential_id.to_owned()))?;
if credential.principal_id != approver {
return Err(refused(format!("the step-up's passkey is not {approver}'s")));
}
if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
return Err(Error::CounterRollback {
id: credential_id.to_owned(),
stored: credential.sign_count,
presented,
});
}
if presented != 0 {
credential.sign_count = presented;
}
credential.last_used = Some(now);
doc.credentials.insert(credential_id.to_owned(), credential)?;
}
row.decided_by = approver.to_owned();
row.decided_at_unix_ms = now;
if approve {
let minutes = minutes.unwrap_or(row.minutes);
row.minutes = minutes;
row.state = OperatorAccessState::Approved;
row.granted_until_unix_ms = now + i64::from(minutes) * 60_000;
} else {
row.state = OperatorAccessState::Refused;
}
doc.operator_access.insert(k, row.clone())?;
Ok(row)
})
}
pub fn revoke_operator_access(&self, id: &str, by: &str, now: i64) -> Result<OperatorAccess> {
self.doc.update(|doc| {
let Some((k, mut row)) = Self::find(doc, id)? else {
return Err(refused(format!("no operator access request {id}")));
};
if by != row.operator && !Self::admin_in(doc, by, &row.account)? {
return Err(refused(format!(
"{by} is not an admin of account {}: only the account's admin (or the operator that asked) ends a look",
row.account
)));
}
match row.state_at(now) {
OperatorAccessState::Pending | OperatorAccessState::Approved => {}
other => return Err(refused(format!("request {id} is {} already", other.as_str()))),
}
row.state = OperatorAccessState::Revoked;
if row.granted_until_unix_ms > now {
row.granted_until_unix_ms = now;
}
row.decided_by = by.to_owned();
row.decided_at_unix_ms = now;
doc.operator_access.insert(k, row.clone())?;
Ok(row)
})
}
pub fn operator_access(&self, account: Option<&str>) -> Result<Vec<OperatorAccess>> {
self.operator_access_at(account, crate::model::now_ms())
}
pub fn operator_access_at(
&self,
account: Option<&str>,
now: i64,
) -> Result<Vec<OperatorAccess>> {
let mut rows: Vec<OperatorAccess> = self
.doc
.load()?
.operator_access
.values()?
.into_iter()
.filter(|row| account.is_none_or(|a| row.account == a))
.map(|row| row.as_at(now))
.collect();
rows.sort_by_key(|row| std::cmp::Reverse(row.requested_at_unix_ms));
Ok(rows)
}
pub fn operator_access_by_id(&self, id: &str) -> Result<Option<OperatorAccess>> {
let now = crate::model::now_ms();
let doc = self.doc.load()?;
Ok(Self::find(&doc, id)?.map(|(_, row)| row.as_at(now)))
}
}