use crate::Visibility;
use crate::authz::{Action, AuthorizedAction};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditActor {
Did(String),
Member,
}
impl AuditActor {
pub fn did(&self) -> Option<&str> {
match self {
AuditActor::Did(d) => Some(d),
AuditActor::Member => None,
}
}
pub fn as_str(&self) -> &str {
match self {
AuditActor::Did(d) => d,
AuditActor::Member => "member",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoomAudit {
pub actor: AuditActor,
pub room_id: String,
pub action: &'static str,
pub record_key: Option<String>,
}
pub fn for_operation(
visibility: Visibility,
authorized: &AuthorizedAction,
record_key: Option<&str>,
) -> RoomAudit {
RoomAudit {
actor: if visibility.discloses_actor() {
AuditActor::Did(authorized.subject().to_string())
} else {
AuditActor::Member
},
room_id: authorized.room_id().to_string(),
action: authorized.action().as_str(),
record_key: record_key.map(str::to_string),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoomOperation {
GetRecord,
ListRecords,
PutRecord,
CurateRecord,
MintEpoch,
TransferOwner,
ClaimOwner,
}
impl RoomOperation {
pub fn action_name(self) -> &'static str {
match self {
RoomOperation::GetRecord => "room.records.get",
RoomOperation::ListRecords => "room.records.list",
RoomOperation::PutRecord => "room.records.put",
RoomOperation::CurateRecord => "room.records.curate",
RoomOperation::MintEpoch => "room.epoch.mint",
RoomOperation::TransferOwner => "room.owner.transfer",
RoomOperation::ClaimOwner => "room.owner.claim",
}
}
pub fn required_action(self) -> Action {
match self {
RoomOperation::GetRecord | RoomOperation::ListRecords | RoomOperation::ClaimOwner => {
Action::Read
}
RoomOperation::PutRecord => Action::Write,
RoomOperation::CurateRecord => Action::Curate,
RoomOperation::MintEpoch | RoomOperation::TransferOwner => Action::Admin,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Room;
use crate::authz::{ChainVerifier, VerifiedChain, authorize};
use crate::wire::AuthorityPresentation;
use vti_common::error::AppError;
const PRESENTER: &str = "did:key:zAgent";
const NOW: u64 = 1_800_000_000;
struct Vouches;
#[async_trait::async_trait]
impl ChainVerifier for Vouches {
async fn verify(
&self,
_: &Room,
_: &AuthorityPresentation,
_: Action,
presenter: &str,
) -> Result<VerifiedChain, AppError> {
Ok(VerifiedChain {
subject: presenter.to_string(),
actions: vec!["read".into(), "write".into()],
})
}
}
async fn authorized(visibility: Visibility) -> AuthorizedAction {
let room = Room {
room_id: "did:key:zRoom".into(),
owner_did: "did:key:zOwner".into(),
visibility,
retention_policy: crate::RetentionPolicy::Chained,
anchor_cadence: Default::default(),
epoch: 1,
next_version: 1,
retention_days: 90,
epoch_expires_at: None,
created_at: 0,
updated_at: 0,
mirror_of: None,
};
let presentation = AuthorityPresentation {
membership: "vmc".into(),
authority: vec!["vac".into()],
subject_binding: Some("binding".into()),
};
authorize(&room, &presentation, Action::Read, PRESENTER, NOW, &Vouches)
.await
.expect("authorized")
}
#[tokio::test]
async fn a_disclosing_tier_records_the_actor() {
for v in [Visibility::Open, Visibility::Attributed] {
let a = authorized(v).await;
let entry = for_operation(v, &a, Some("k1"));
assert_eq!(entry.actor, AuditActor::Did(PRESENTER.into()), "{v:?}");
assert_eq!(entry.actor.did(), Some(PRESENTER));
}
}
#[tokio::test]
async fn a_private_room_records_that_a_member_acted_and_never_who() {
let a = authorized(Visibility::Private).await;
let entry = for_operation(Visibility::Private, &a, Some("opaque-key"));
assert_eq!(entry.actor, AuditActor::Member);
assert_eq!(
entry.actor.did(),
None,
"there must be no way to get a DID back out"
);
assert!(
!format!("{entry:?}").contains(PRESENTER),
"the presenter must not survive anywhere in the entry: {entry:?}"
);
}
#[tokio::test]
async fn the_room_and_record_are_recorded_on_every_tier() {
for v in [
Visibility::Open,
Visibility::Attributed,
Visibility::Private,
] {
let a = authorized(v).await;
let entry = for_operation(v, &a, Some("k1"));
assert_eq!(entry.room_id, "did:key:zRoom");
assert_eq!(entry.record_key.as_deref(), Some("k1"));
assert_eq!(entry.action, "read");
}
}
#[test]
fn listing_and_fetching_are_distinct_actions_in_the_log() {
assert_eq!(
RoomOperation::ListRecords.action_name(),
"room.records.list"
);
assert_eq!(RoomOperation::GetRecord.action_name(), "room.records.get");
assert_eq!(
RoomOperation::ListRecords.required_action(),
RoomOperation::GetRecord.required_action(),
"the same authority, and that is exactly why the action name has to differ"
);
}
#[test]
fn every_operation_has_its_own_name() {
let all = [
RoomOperation::GetRecord,
RoomOperation::ListRecords,
RoomOperation::PutRecord,
RoomOperation::CurateRecord,
RoomOperation::MintEpoch,
RoomOperation::TransferOwner,
RoomOperation::ClaimOwner,
];
let mut names: Vec<_> = all.iter().map(|o| o.action_name()).collect();
names.sort_unstable();
let count = names.len();
names.dedup();
assert_eq!(names.len(), count, "two operations share an audit name");
for op in all {
assert!(
op.action_name().starts_with("room."),
"{op:?} is outside the room.* vocabulary"
);
}
}
#[test]
fn a_claim_asks_for_membership_not_admin() {
assert_eq!(RoomOperation::ClaimOwner.required_action(), Action::Read);
assert_eq!(
RoomOperation::TransferOwner.required_action(),
Action::Admin,
"an owner handing the room away is the most consequential thing they do"
);
}
}