use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use vti_common::error::AppError;
use vti_common::store::{Batch, KeyspaceHandle};
pub const MAX_LIFETIME_DAYS: i64 = 30;
pub const MAX_ACTIVE_PER_IDENTITY: usize = 5;
pub static DELEGATION_LOCK: Mutex<()> = Mutex::const_new(());
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum DelegationScope {
#[default]
Console,
}
impl DelegationScope {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Console => "console",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", from = "StoredDelegation")]
pub struct ConsoleKeyDelegation {
pub console_did: String,
pub admin_did: String,
#[serde(default)]
pub scope: DelegationScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_by: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredDelegation {
console_did: String,
admin_did: String,
#[serde(default)]
scope: DelegationScope,
#[serde(default)]
label: Option<String>,
created_at: DateTime<Utc>,
#[serde(default)]
expires_at: Option<DateTime<Utc>>,
#[serde(default)]
last_used_at: Option<DateTime<Utc>>,
#[serde(default)]
revoked_at: Option<DateTime<Utc>>,
#[serde(default)]
revoked_by: Option<String>,
}
impl From<StoredDelegation> for ConsoleKeyDelegation {
fn from(s: StoredDelegation) -> Self {
Self {
expires_at: s
.expires_at
.unwrap_or(s.created_at + Duration::days(MAX_LIFETIME_DAYS)),
console_did: s.console_did,
admin_did: s.admin_did,
scope: s.scope,
label: s.label,
created_at: s.created_at,
last_used_at: s.last_used_at,
revoked_at: s.revoked_at,
revoked_by: s.revoked_by,
}
}
}
impl ConsoleKeyDelegation {
#[must_use]
pub fn is_active_at(&self, now: DateTime<Utc>) -> bool {
self.revoked_at.is_none() && self.expires_at > now
}
}
const PREFIX: &[u8] = b"console_key:";
fn key(console_did: &str) -> Vec<u8> {
let mut k = PREFIX.to_vec();
k.extend_from_slice(console_did.as_bytes());
k
}
pub async fn get_delegation(
ks: &KeyspaceHandle,
console_did: &str,
) -> Result<Option<ConsoleKeyDelegation>, AppError> {
ks.get(key(console_did)).await
}
pub async fn store_delegation(
ks: &KeyspaceHandle,
delegation: &ConsoleKeyDelegation,
) -> Result<(), AppError> {
ks.insert(key(&delegation.console_did), delegation).await
}
pub async fn list_delegations_for_admin(
ks: &KeyspaceHandle,
admin_did: &str,
) -> Result<Vec<ConsoleKeyDelegation>, AppError> {
let raw = ks.prefix_iter_raw(PREFIX.to_vec()).await?;
let mut out: Vec<ConsoleKeyDelegation> = Vec::new();
for (_k, v) in raw {
match serde_json::from_slice::<ConsoleKeyDelegation>(&v) {
Ok(d) if d.admin_did == admin_did => out.push(d),
Ok(_) => {}
Err(err) => tracing::warn!(error = %err, "skipping unparseable console-key row"),
}
}
out.sort_by_key(|d| std::cmp::Reverse(d.created_at));
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnrolError {
NotADidKey,
SelfDelegation,
SubjectHoldsAclRow,
AlreadyDelegated,
Revoked,
}
impl EnrolError {
#[must_use]
pub fn into_app_error(self, console_did: &str) -> AppError {
match self {
Self::NotADidKey => AppError::Validation(format!(
"consoleDid must be a did:key (the console derives it from its own \
non-extractable Ed25519 key); got {console_did}"
)),
Self::SelfDelegation => AppError::Validation(
"consoleDid must not be your own admin DID — a delegation names a \
*separate* key that may act as it"
.into(),
),
Self::SubjectHoldsAclRow => AppError::Conflict(format!(
"{console_did} already holds an ACL row of its own, so it is an identity \
rather than a console key; delegating to it would take effect only once \
that row was removed"
)),
Self::AlreadyDelegated => AppError::Conflict(format!(
"{console_did} is already enrolled as a console key; revoke it before \
enrolling it again"
)),
Self::Revoked => AppError::Conflict(format!(
"{console_did} was revoked and cannot be re-enrolled; generate a new \
console key in the browser"
)),
}
}
}
pub async fn key_refusal(
console_keys_ks: &KeyspaceHandle,
acl_ks: &KeyspaceHandle,
console_did: &str,
admin_did: &str,
) -> Result<Option<EnrolError>, AppError> {
if !console_did.starts_with("did:key:") {
return Ok(Some(EnrolError::NotADidKey));
}
if console_did == admin_did {
return Ok(Some(EnrolError::SelfDelegation));
}
if let Some(existing) = get_delegation(console_keys_ks, console_did).await? {
return Ok(Some(if existing.revoked_at.is_some() {
EnrolError::Revoked
} else {
EnrolError::AlreadyDelegated
}));
}
if crate::acl::get_acl_entry(acl_ks, console_did)
.await?
.is_some()
{
return Ok(Some(EnrolError::SubjectHoldsAclRow));
}
Ok(None)
}
#[must_use]
pub fn capped_expiry(requested: Option<DateTime<Utc>>, now: DateTime<Utc>) -> DateTime<Utc> {
let ceiling = now + Duration::days(MAX_LIFETIME_DAYS);
requested.map_or(ceiling, |r| r.min(ceiling))
}
pub async fn enrol_delegation(
console_keys_ks: &KeyspaceHandle,
acl_ks: &KeyspaceHandle,
console_did: &str,
admin_did: &str,
label: Option<String>,
expires_at: Option<DateTime<Utc>>,
) -> Result<ConsoleKeyDelegation, AppError> {
if let Some(refusal) = key_refusal(console_keys_ks, acl_ks, console_did, admin_did).await? {
return Err(refusal.into_app_error(console_did));
}
let now = Utc::now();
let delegation = ConsoleKeyDelegation {
console_did: console_did.to_string(),
admin_did: admin_did.to_string(),
scope: DelegationScope::Console,
label: label.filter(|l| !l.trim().is_empty()),
created_at: now,
expires_at: capped_expiry(expires_at, now),
last_used_at: None,
revoked_at: None,
revoked_by: None,
};
store_delegation(console_keys_ks, &delegation).await?;
Ok(delegation)
}
pub async fn active_count(
ks: &KeyspaceHandle,
admin_did: &str,
now: DateTime<Utc>,
) -> Result<usize, AppError> {
Ok(list_delegations_for_admin(ks, admin_did)
.await?
.iter()
.filter(|d| d.is_active_at(now))
.count())
}
pub async fn revoke_delegation(
ks: &KeyspaceHandle,
console_did: &str,
revoked_by: &str,
) -> Result<Option<ConsoleKeyDelegation>, AppError> {
let k = key(console_did);
loop {
let Some(raw) = ks.get_raw(k.clone()).await? else {
return Ok(None);
};
let mut delegation: ConsoleKeyDelegation = serde_json::from_slice(&raw)?;
if delegation.revoked_at.is_some() {
return Ok(Some(delegation));
}
delegation.revoked_at = Some(Utc::now());
delegation.revoked_by = Some(revoked_by.to_string());
let mut batch = Batch::new();
batch
.require_unchanged(k.clone(), raw)
.insert(k.clone(), &delegation)?;
if ks.apply_batch(batch).await?.is_applied() {
return Ok(Some(delegation));
}
}
}
pub async fn resolve_delegated_admin(
ks: &KeyspaceHandle,
signer_did: &str,
) -> Result<Option<ConsoleKeyDelegation>, AppError> {
Ok(get_delegation(ks, signer_did)
.await?
.filter(|d| d.is_active_at(Utc::now())))
}
pub async fn touch_last_used(ks: &KeyspaceHandle, delegation: &ConsoleKeyDelegation) {
if let Err(err) = stamp_last_used(ks, &delegation.console_did).await {
tracing::warn!(
console_did = %delegation.console_did,
error = %err,
"could not stamp console-key last-used"
);
}
}
async fn stamp_last_used(ks: &KeyspaceHandle, console_did: &str) -> Result<(), AppError> {
let k = key(console_did);
let Some(raw) = ks.get_raw(k.clone()).await? else {
return Ok(());
};
let mut current: ConsoleKeyDelegation = serde_json::from_slice(&raw)?;
let now = Utc::now();
if !current.is_active_at(now) {
return Ok(());
}
current.last_used_at = Some(now);
let mut batch = Batch::new();
batch
.require_unchanged(k.clone(), raw)
.insert(k, ¤t)?;
ks.apply_batch(batch).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{VtcAclEntry, VtcRole, store_acl_entry};
use chrono::Duration;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
const CONSOLE: &str = "did:key:z6MkConsoleBrowserOne";
const ADMIN: &str = "did:key:z6MkAdminOperator";
#[test]
fn a_row_stored_before_scope_and_expiry_still_reads() {
let row = r#"{"consoleDid":"did:key:z6MkOld","adminDid":"did:key:z6MkAdmin","createdAt":"2026-09-01T00:00:00Z"}"#;
let d: ConsoleKeyDelegation = serde_json::from_str(row).expect("legacy row reads");
assert_eq!(d.scope, DelegationScope::Console);
assert_eq!(
d.expires_at,
d.created_at + Duration::days(MAX_LIFETIME_DAYS)
);
}
fn temp_store() -> (KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&cfg).expect("store");
let console = store.keyspace("console-keys-test").expect("ks");
let acl = store.keyspace("console-keys-acl-test").expect("ks");
(console, acl, dir)
}
async fn seed_acl(acl: &KeyspaceHandle, did: &str, role: VtcRole) {
store_acl_entry(
acl,
&VtcAclEntry {
did: did.into(),
admin: role.implied_authority(),
delegated_by: None,
role,
label: None,
created_at: 0,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
)
.await
.expect("seed ACL row");
}
#[tokio::test]
async fn a_delegation_round_trips_and_resolves() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
let d = enrol_delegation(
&console_ks,
&acl_ks,
CONSOLE,
ADMIN,
Some("Work laptop".into()),
None,
)
.await
.expect("enrol");
assert_eq!(d.admin_did, ADMIN);
assert_eq!(d.label.as_deref(), Some("Work laptop"));
let resolved = resolve_delegated_admin(&console_ks, CONSOLE)
.await
.expect("resolve")
.expect("active");
assert_eq!(resolved.admin_did, ADMIN);
}
#[tokio::test]
async fn a_revoked_delegation_stops_resolving_immediately() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect("enrol");
revoke_delegation(&console_ks, CONSOLE, ADMIN)
.await
.expect("revoke")
.expect("existed");
assert!(
resolve_delegated_admin(&console_ks, CONSOLE)
.await
.expect("resolve")
.is_none(),
"a revoked delegation must resolve to nothing"
);
}
#[tokio::test]
async fn a_last_used_stamp_never_reactivates_a_revoked_delegation() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect("enrol");
let stale = resolve_delegated_admin(&console_ks, CONSOLE)
.await
.expect("resolve")
.expect("active");
revoke_delegation(&console_ks, CONSOLE, ADMIN)
.await
.expect("revoke")
.expect("existed");
touch_last_used(&console_ks, &stale).await;
assert!(
resolve_delegated_admin(&console_ks, CONSOLE)
.await
.expect("resolve")
.is_none(),
"a last-used stamp reactivated a revoked console key"
);
}
#[tokio::test]
async fn a_last_used_stamp_records_use_of_a_live_delegation() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect("enrol");
let d = get_delegation(&console_ks, CONSOLE).await.unwrap().unwrap();
assert!(d.last_used_at.is_none());
touch_last_used(&console_ks, &d).await;
let d = get_delegation(&console_ks, CONSOLE).await.unwrap().unwrap();
assert!(d.last_used_at.is_some());
}
#[tokio::test]
async fn an_expired_delegation_stops_resolving() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
enrol_delegation(
&console_ks,
&acl_ks,
CONSOLE,
ADMIN,
None,
Some(Utc::now() - Duration::seconds(1)),
)
.await
.expect("enrol");
assert!(
resolve_delegated_admin(&console_ks, CONSOLE)
.await
.expect("resolve")
.is_none()
);
}
#[tokio::test]
async fn a_did_with_its_own_acl_row_cannot_be_enrolled() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
let other_admin = "did:key:z6MkAnotherAdmin";
seed_acl(&acl_ks, other_admin, VtcRole::Admin).await;
let err = enrol_delegation(&console_ks, &acl_ks, other_admin, ADMIN, None, None)
.await
.expect_err("must refuse");
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
}
#[tokio::test]
async fn a_second_admin_cannot_repoint_an_enrolled_console_key() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
let rival = "did:key:z6MkRivalAdmin";
seed_acl(&acl_ks, rival, VtcRole::Admin).await;
enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect("enrol");
let err = enrol_delegation(&console_ks, &acl_ks, CONSOLE, rival, None, None)
.await
.expect_err("must refuse");
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
assert_eq!(
get_delegation(&console_ks, CONSOLE)
.await
.expect("read")
.expect("present")
.admin_did,
ADMIN,
"the first enrolment stands"
);
}
#[tokio::test]
async fn a_revoked_console_key_cannot_be_re_enrolled() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect("enrol");
revoke_delegation(&console_ks, CONSOLE, ADMIN)
.await
.expect("revoke");
let err = enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect_err("must refuse");
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
}
#[tokio::test]
async fn a_non_did_key_and_a_self_delegation_are_refused() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
assert!(matches!(
enrol_delegation(
&console_ks,
&acl_ks,
"did:web:console.example.com",
ADMIN,
None,
None
)
.await
.expect_err("must refuse"),
AppError::Validation(_)
));
assert!(matches!(
enrol_delegation(&console_ks, &acl_ks, ADMIN, ADMIN, None, None)
.await
.expect_err("must refuse"),
AppError::Validation(_)
));
}
#[tokio::test]
async fn listing_is_scoped_to_the_enrolling_admin() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
let other = "did:key:z6MkOtherOperator";
seed_acl(&acl_ks, other, VtcRole::Admin).await;
enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect("enrol");
enrol_delegation(
&console_ks,
&acl_ks,
"did:key:z6MkTheirBrowser",
other,
None,
None,
)
.await
.expect("enrol");
let mine = list_delegations_for_admin(&console_ks, ADMIN)
.await
.expect("list");
assert_eq!(mine.len(), 1);
assert_eq!(mine[0].console_did, CONSOLE);
}
#[tokio::test]
async fn revoking_is_idempotent_and_keeps_the_first_revoker() {
let (console_ks, acl_ks, _dir) = temp_store();
seed_acl(&acl_ks, ADMIN, VtcRole::Admin).await;
enrol_delegation(&console_ks, &acl_ks, CONSOLE, ADMIN, None, None)
.await
.expect("enrol");
let first = revoke_delegation(&console_ks, CONSOLE, ADMIN)
.await
.expect("revoke")
.expect("existed");
let second = revoke_delegation(&console_ks, CONSOLE, "did:key:z6MkSomeoneElse")
.await
.expect("revoke again")
.expect("existed");
assert_eq!(first.revoked_at, second.revoked_at);
assert_eq!(second.revoked_by.as_deref(), Some(ADMIN));
}
#[tokio::test]
async fn an_unknown_signer_resolves_to_nothing() {
let (console_ks, _acl_ks, _dir) = temp_store();
assert!(
resolve_delegated_admin(&console_ks, "did:key:z6MkNeverSeen")
.await
.expect("resolve")
.is_none()
);
}
}