use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use arora_bridge::{AccessDecision, AccessRequestStream, DeviceInfo};
use async_trait::async_trait;
use futures::StreamExt;
use log::{info, warn};
use crate::runtime::Telemetry;
pub const DEFAULT_ACCESS_GRACE: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub struct AccessRequestSummary {
pub client_id: String,
pub user_id: Option<String>,
pub permission: String,
}
impl AccessRequestSummary {
pub fn subject(&self) -> &str {
self.user_id.as_deref().unwrap_or(&self.client_id)
}
pub fn describe(&self) -> String {
let client = shorten(&self.client_id);
match &self.user_id {
Some(user) => format!(
"Studio client {client} (user {}) wants to {}",
shorten(user),
self.permission
),
None => format!("Studio client {client} wants to {}", self.permission),
}
}
}
pub fn shorten(id: &str) -> &str {
match id.char_indices().nth(8) {
Some((idx, _)) => &id[..idx],
None => id,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccessRuling {
pub decision: AccessDecision,
pub remember: bool,
}
#[async_trait]
pub trait Operator: Send + Sync {
async fn ask_text(&self, label: &str, required: bool) -> Option<String>;
async fn decide_access(&self, request: &AccessRequestSummary) -> AccessRuling;
}
pub struct UnattendedOperator {
grace: Duration,
}
impl UnattendedOperator {
pub fn new(grace: Duration) -> Self {
Self { grace }
}
}
impl Default for UnattendedOperator {
fn default() -> Self {
Self::new(DEFAULT_ACCESS_GRACE)
}
}
#[async_trait]
impl Operator for UnattendedOperator {
async fn ask_text(&self, label: &str, required: bool) -> Option<String> {
if required {
warn!("unattended: no operator to answer required prompt \"{label}\"");
}
None
}
async fn decide_access(&self, request: &AccessRequestSummary) -> AccessRuling {
info!(
"{} — allowing in {}s (unattended)",
request.describe(),
self.grace.as_secs()
);
tokio::time::sleep(self.grace).await;
AccessRuling {
decision: AccessDecision::Allowed,
remember: false,
}
}
}
pub struct Frontend {
pub operator: Arc<dyn Operator>,
pub on_ready: ReadyHook,
}
pub type ReadyHook = Box<dyn FnOnce(Telemetry, Option<DeviceInfo>, Option<String>) + Send>;
pub fn default_frontend() -> Frontend {
let _ = env_logger::try_init();
Frontend {
operator: Arc::new(UnattendedOperator::default()),
on_ready: Box::new(|_telemetry, _info, _device_id| {}),
}
}
pub async fn serve_access_requests(requests: AccessRequestStream, operator: Arc<dyn Operator>) {
let mut remembered: HashMap<String, AccessDecision> = HashMap::new();
let mut requests = requests;
while let Some(request) = requests.next().await {
let summary = AccessRequestSummary {
client_id: request.client_id.clone(),
user_id: request.user_id.clone(),
permission: request.permission.clone(),
};
if let Some(decision) = remembered.get(summary.subject()) {
info!(
"{} — {:?} (remembered ruling)",
summary.describe(),
decision
);
request.respond(*decision);
continue;
}
let ruling = operator.decide_access(&summary).await;
info!("{} — {:?}", summary.describe(), ruling.decision);
if ruling.remember {
remembered.insert(summary.subject().to_string(), ruling.decision);
}
request.respond(ruling.decision);
}
}
#[cfg(test)]
mod tests {
use super::*;
use arora_bridge::AccessRequest;
use std::sync::Mutex;
struct Scripted {
rulings: Mutex<Vec<AccessRuling>>,
seen: Mutex<Vec<String>>,
}
#[async_trait]
impl Operator for Scripted {
async fn ask_text(&self, _label: &str, _required: bool) -> Option<String> {
None
}
async fn decide_access(&self, request: &AccessRequestSummary) -> AccessRuling {
self.seen
.lock()
.unwrap()
.push(request.subject().to_string());
self.rulings.lock().unwrap().remove(0)
}
}
#[tokio::test]
async fn remembered_rulings_short_circuit_the_operator() {
let (req1, rx1) = AccessRequest::new("c1", Some("user-a".into()), "join the session");
let (req2, rx2) = AccessRequest::new("c2", Some("user-a".into()), "join the session");
let (req3, rx3) = AccessRequest::new("c3", Some("user-b".into()), "join the session");
let operator = Arc::new(Scripted {
rulings: Mutex::new(vec![
AccessRuling {
decision: AccessDecision::Rejected,
remember: true,
},
AccessRuling {
decision: AccessDecision::Allowed,
remember: false,
},
]),
seen: Mutex::new(Vec::new()),
});
let stream: AccessRequestStream = Box::pin(futures::stream::iter([req1, req2, req3]));
serve_access_requests(stream, operator.clone()).await;
assert_eq!(rx1.await.unwrap(), AccessDecision::Rejected);
assert_eq!(rx2.await.unwrap(), AccessDecision::Rejected);
assert_eq!(rx3.await.unwrap(), AccessDecision::Allowed);
assert_eq!(*operator.seen.lock().unwrap(), vec!["user-a", "user-b"]);
}
#[tokio::test(start_paused = true)]
async fn unattended_operator_allows_after_the_grace_period() {
let (req, rx) = AccessRequest::new("c1", None, "join the session");
let stream: AccessRequestStream = Box::pin(futures::stream::iter([req]));
let served = tokio::spawn(serve_access_requests(
stream,
Arc::new(UnattendedOperator::default()),
));
served.await.unwrap();
assert_eq!(rx.await.unwrap(), AccessDecision::Allowed);
}
#[test]
fn summaries_describe_the_request_with_shortened_ids() {
let summary = AccessRequestSummary {
client_id: "0123456789abcdef".into(),
user_id: Some("fedcba9876543210".into()),
permission: "join the session".into(),
};
assert_eq!(
summary.describe(),
"Studio client 01234567 (user fedcba98) wants to join the session"
);
assert_eq!(summary.subject(), "fedcba9876543210");
}
}