use serde::{Deserialize, Serialize};
use vti_common::audit::{AuditEvent, MemberPortalKeyUsedData};
use vti_common::auth::session::{SessionState, get_session, now_epoch};
use vti_common::store::KeyspaceHandle;
use crate::error::AppError;
fn key(start_key: &str) -> Vec<u8> {
format!("delegation:{start_key}").into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Delegation {
pub member_did: String,
pub session_id: String,
pub scope: Vec<String>,
pub not_after: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grant_id: Option<String>,
}
const EXCLUDED: &[&str] = &["auth", "acl", "consent", "task-consent", "confirm"];
fn slug_of(type_uri: &str) -> Option<&str> {
let path = type_uri.split('#').next()?;
let path = path.split_once("/spec/")?.1;
Some(path.rsplit_once('/')?.0)
}
fn under(slug: &str, family: &str) -> bool {
slug == family || slug.starts_with(&format!("{family}/"))
}
pub fn covers(scope: &[String], type_uri: &str) -> bool {
let Some(slug) = slug_of(type_uri) else {
return false;
};
!EXCLUDED.iter().any(|f| under(slug, f)) && scope.iter().any(|f| under(slug, f))
}
pub async fn record(
ks: &KeyspaceHandle,
start_key: &str,
delegation: &Delegation,
) -> Result<(), AppError> {
ks.insert(key(start_key), delegation).await
}
pub async fn member_for(
ks: &KeyspaceHandle,
signer: &str,
type_uri: &str,
) -> Result<Option<String>, AppError> {
Ok(armed(ks, signer, type_uri).await?.map(|d| d.member_did))
}
pub async fn accept(
state: &crate::server::AppState,
signer: &str,
type_uri: &str,
document_id: &str,
) -> Result<Option<String>, AppError> {
let Some(d) = armed(&state.member_sessions_ks, signer, type_uri).await? else {
return Ok(None);
};
let writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: axum::http::StatusCode::SERVICE_UNAVAILABLE,
message: "audit writer not configured: a document the portal's sign-in key signed \
is refused rather than accepted unaudited"
.into(),
})?;
writer
.write(
&d.member_did,
Some(&d.member_did),
AuditEvent::MemberPortalKeyUsed(MemberPortalKeyUsedData {
member: d.member_did.clone(),
start_key_did: signer.to_string(),
grant_id: d.grant_id.clone(),
task_type: type_uri.to_string(),
document_id: document_id.to_string(),
}),
)
.await?;
Ok(Some(d.member_did))
}
async fn armed(
ks: &KeyspaceHandle,
signer: &str,
type_uri: &str,
) -> Result<Option<Delegation>, AppError> {
let Some(d): Option<Delegation> = ks.get(key(signer)).await? else {
return Ok(None);
};
let now = now_epoch();
let session = get_session(ks, &d.session_id).await?;
let live = session.as_ref().is_some_and(|s| {
s.state == SessionState::Authenticated
&& s.did == d.member_did
&& s.not_after.is_none_or(|end| end > now)
&& s.refresh_expires_at.is_none_or(|end| end > now)
&& s.session_pubkey_b58btc.as_deref()
== crate::member_portal::oob::did_key_multikey(signer)
});
if !live || d.not_after <= now {
if session.is_none() || d.not_after <= now {
ks.remove(key(signer)).await?;
}
return Ok(None);
}
Ok(covers(&d.scope, type_uri).then_some(d))
}
#[cfg(test)]
mod tests {
use super::*;
const S: &str = "https://trusttasks.org/spec";
#[test]
fn a_scope_covers_whole_segments_only() {
let scope = vec!["vtc/directory".to_string()];
assert!(covers(
&scope,
&format!("{S}/vtc/directory/card/update/0.1")
));
assert!(covers(&scope, &format!("{S}/vtc/directory/query/0.1")));
assert!(!covers(
&scope,
&format!("{S}/vtc/directoryx/card/update/0.1")
));
assert!(!covers(&scope, &format!("{S}/vtc/groups/join/0.1")));
}
#[test]
fn exclusions_hold_whatever_the_scope_says() {
let scope = vec![
"auth".to_string(),
"acl".to_string(),
"task-consent".to_string(),
];
assert!(!covers(&scope, &format!("{S}/auth/oob/grant/0.2")));
assert!(!covers(&scope, &format!("{S}/acl/grant/0.2")));
assert!(!covers(&scope, &format!("{S}/task-consent/decision/0.1")));
}
}