#![allow(clippy::result_large_err)]
use std::sync::Arc;
use chrono::Utc;
use futures::future::BoxFuture;
use serde::Serialize;
use serde_json::Value;
use trust_tasks_rs::{Dispatcher, ErrorResponse, RejectReason, TrustTask};
use uuid::Uuid;
use crate::domain::TrustRecord;
use crate::storage::repository::{
RepositoryError, TrustRecordAdminRepository, TrustRecordRepository,
};
use super::payloads::{
AuthorizationRequest, AuthorizationResponse, AuthorizationResponseMessage, RecognitionRequest,
RecognitionResponse, RecognitionResponseMessage, RecordDeleteRequest, RecordDeleteResponse,
RecordPutRequest, RecordPutResponse, RecordQueryRequest, RecordQueryResponse, SpecTrustRecord,
query_of, reserialize,
};
const QUERY_DEFAULT_LIMIT: usize = 50;
const QUERY_MAX_LIMIT: usize = 200;
pub type TaskOutcome = Result<TrustTask<Value>, ErrorResponse>;
pub type TaskFuture = BoxFuture<'static, TaskOutcome>;
pub type RegistryDispatcher = Dispatcher<TaskFuture>;
fn new_id() -> String {
Uuid::new_v4().to_string()
}
fn map_repo_err(err: RepositoryError) -> RejectReason {
match err {
RepositoryError::ValidationError(reason) => RejectReason::MalformedRequest { reason },
RepositoryError::RecordNotFound(reason) | RepositoryError::RecordAlreadyExists(reason) => {
RejectReason::TaskFailed {
reason,
details: None,
}
}
RepositoryError::ConnectionFailed(reason)
| RepositoryError::QueryFailed(reason)
| RepositoryError::SerializationFailed(reason) => RejectReason::InternalError { reason },
RepositoryError::LockPoisoned => RejectReason::InternalError {
reason: "lock poisoned".to_string(),
},
}
}
fn respond<P, T: Serialize>(doc: &TrustTask<P>, payload: T) -> TaskOutcome {
match serde_json::to_value(payload) {
Ok(value) => Ok(doc.respond_with(new_id(), value)),
Err(e) => Err(doc.reject_with(
new_id(),
RejectReason::InternalError {
reason: e.to_string(),
},
)),
}
}
fn reject_build_err<P>(doc: &TrustTask<P>, err: impl std::fmt::Display) -> ErrorResponse {
doc.reject_with(
new_id(),
RejectReason::InternalError {
reason: format!("could not build response payload: {err}"),
},
)
}
pub fn build_dispatcher<R>(repository: Arc<R>) -> RegistryDispatcher
where
R: TrustRecordAdminRepository + ?Sized + 'static,
{
Dispatcher::new()
.on::<RecognitionRequest, _>({
let repo = repository.clone();
move |doc| -> TaskFuture { Box::pin(handle_recognition(repo.clone(), doc)) }
})
.on::<AuthorizationRequest, _>({
let repo = repository.clone();
move |doc| -> TaskFuture { Box::pin(handle_authorization(repo.clone(), doc)) }
})
.on::<RecordPutRequest, _>({
let repo = repository.clone();
move |doc| -> TaskFuture { Box::pin(handle_put(repo.clone(), doc)) }
})
.on::<RecordQueryRequest, _>({
let repo = repository.clone();
move |doc| -> TaskFuture { Box::pin(handle_query(repo.clone(), doc)) }
})
.on::<RecordDeleteRequest, _>({
let repo = repository.clone();
move |doc| -> TaskFuture { Box::pin(handle_delete(repo.clone(), doc)) }
})
}
pub fn build_query_dispatcher<R>(repository: Arc<R>) -> RegistryDispatcher
where
R: TrustRecordRepository + ?Sized + 'static,
{
Dispatcher::new()
.on::<RecognitionRequest, _>({
let repo = repository.clone();
move |doc| -> TaskFuture { Box::pin(handle_recognition(repo.clone(), doc)) }
})
.on::<AuthorizationRequest, _>({
let repo = repository.clone();
move |doc| -> TaskFuture { Box::pin(handle_authorization(repo.clone(), doc)) }
})
}
pub async fn handle_document(
dispatcher: &RegistryDispatcher,
doc: TrustTask<Value>,
) -> TaskOutcome {
match dispatcher.dispatch_or_reject(doc, new_id()) {
Ok(future) => future.await,
Err(error_response) => Err(error_response),
}
}
async fn handle_recognition<R>(
repository: Arc<R>,
doc: TrustTask<RecognitionRequest>,
) -> TaskOutcome
where
R: TrustRecordRepository + ?Sized + 'static,
{
let p = &doc.payload;
let query = query_of(&p.entity_id, &p.authority_id, &p.action, &p.resource);
let record = match repository.find_by_query(query).await {
Ok(record) => record,
Err(e) => return Err(doc.reject_with(new_id(), map_repo_err(e))),
};
let evaluated_at = Utc::now();
let message: Option<RecognitionResponseMessage> = record.as_ref().and_then(|tr| {
format!(
"{} recognized by {}",
tr.entity_id().as_str(),
tr.authority_id().as_str()
)
.try_into()
.ok()
});
let response: RecognitionResponse = RecognitionResponse::builder()
.entity_id(p.entity_id.clone())
.authority_id(p.authority_id.clone())
.action(p.action.clone())
.resource(p.resource.clone())
.recognized(record.map(|tr| tr.is_recognized()).unwrap_or(false))
.time_evaluated(evaluated_at)
.time_requested(p.context.as_ref().and_then(|c| c.time))
.message(message)
.try_into()
.map_err(|e| reject_build_err(&doc, e))?;
respond(&doc, response)
}
async fn handle_authorization<R>(
repository: Arc<R>,
doc: TrustTask<AuthorizationRequest>,
) -> TaskOutcome
where
R: TrustRecordRepository + ?Sized + 'static,
{
let p = &doc.payload;
let query = query_of(&p.entity_id, &p.authority_id, &p.action, &p.resource);
let record = match repository.find_by_query(query).await {
Ok(record) => record,
Err(e) => return Err(doc.reject_with(new_id(), map_repo_err(e))),
};
let evaluated_at = Utc::now();
let message: Option<AuthorizationResponseMessage> = record.as_ref().and_then(|tr| {
format!(
"{} authorized to {}+{} by {}",
tr.entity_id().as_str(),
tr.action().as_str(),
tr.resource().as_str(),
tr.authority_id().as_str()
)
.try_into()
.ok()
});
let response: AuthorizationResponse = AuthorizationResponse::builder()
.entity_id(p.entity_id.clone())
.authority_id(p.authority_id.clone())
.action(p.action.clone())
.resource(p.resource.clone())
.authorized(record.map(|tr| tr.is_authorized()).unwrap_or(false))
.time_evaluated(evaluated_at)
.time_requested(p.context.as_ref().and_then(|c| c.time))
.message(message)
.try_into()
.map_err(|e| reject_build_err(&doc, e))?;
respond(&doc, response)
}
fn record_from_payload<P>(
doc: &TrustTask<P>,
spec: &impl Serialize,
) -> Result<TrustRecord, ErrorResponse> {
reserialize(spec)
.map_err(|reason| doc.reject_with(new_id(), RejectReason::MalformedRequest { reason }))
}
async fn handle_put<R>(repository: Arc<R>, doc: TrustTask<RecordPutRequest>) -> TaskOutcome
where
R: TrustRecordAdminRepository + ?Sized + 'static,
{
let record = record_from_payload(&doc, &doc.payload.record)?;
let outcome = match doc.payload.expected_existing {
Some(true) => repository.update(record).await.map(|()| false),
Some(false) => repository.create(record).await.map(|()| true),
None => match repository.create(record.clone()).await {
Ok(()) => Ok(true),
Err(RepositoryError::RecordAlreadyExists(_)) => {
repository.update(record).await.map(|()| false)
}
Err(e) => Err(e),
},
};
match outcome {
Ok(created) => respond(
&doc,
RecordPutResponse {
ok: true,
created,
message: None,
},
),
Err(e) => Err(doc.reject_with(new_id(), map_repo_err(e))),
}
}
async fn handle_delete<R>(repository: Arc<R>, doc: TrustTask<RecordDeleteRequest>) -> TaskOutcome
where
R: TrustRecordAdminRepository + ?Sized + 'static,
{
let p = &doc.payload;
let query = query_of(&p.entity_id, &p.authority_id, &p.action, &p.resource);
match repository.delete(query).await {
Ok(()) => {
let response: RecordDeleteResponse = RecordDeleteResponse::builder()
.ok(true)
.try_into()
.map_err(|e| reject_build_err(&doc, e))?;
respond(&doc, response)
}
Err(e) => Err(doc.reject_with(new_id(), map_repo_err(e))),
}
}
async fn handle_query<R>(repository: Arc<R>, doc: TrustTask<RecordQueryRequest>) -> TaskOutcome
where
R: TrustRecordAdminRepository + ?Sized + 'static,
{
let p = &doc.payload;
if let (Some(entity_id), Some(authority_id), Some(action), Some(resource)) =
(&p.entity_id, &p.authority_id, &p.action, &p.resource)
{
let query = query_of(entity_id, authority_id, action, resource);
return match repository.read(query).await {
Ok(record) => match reserialize::<_, SpecTrustRecord>(&record) {
Ok(record) => respond(
&doc,
RecordQueryResponse {
records: vec![record],
next_cursor: None,
},
),
Err(reason) => {
Err(doc.reject_with(new_id(), RejectReason::InternalError { reason }))
}
},
Err(e) => Err(doc.reject_with(new_id(), map_repo_err(e))),
};
}
let offset: usize = match p.cursor.as_deref() {
None => 0,
Some(cursor) => match cursor.parse() {
Ok(offset) => offset,
Err(_) => {
return Err(doc.reject_with(
new_id(),
RejectReason::MalformedRequest {
reason: "unrecognized cursor".to_string(),
},
));
}
},
};
let limit = p
.limit
.map_or(QUERY_DEFAULT_LIMIT, |l| l as usize)
.clamp(1, QUERY_MAX_LIMIT);
let list = match repository.list().await {
Ok(list) => list,
Err(e) => return Err(doc.reject_with(new_id(), map_repo_err(e))),
};
let field_matches =
|filter: Option<&str>, value: &str| filter.is_none_or(|wanted| wanted == value);
let mut matches: Vec<TrustRecord> = list
.into_records()
.into_iter()
.filter(|r| {
field_matches(p.entity_id.as_deref(), r.entity_id().as_str())
&& field_matches(p.authority_id.as_deref(), r.authority_id().as_str())
&& field_matches(p.action.as_deref(), r.action().as_str())
&& field_matches(p.resource.as_deref(), r.resource().as_str())
})
.collect();
matches.sort_by(|a, b| {
(
a.entity_id().as_str(),
a.authority_id().as_str(),
a.action().as_str(),
a.resource().as_str(),
)
.cmp(&(
b.entity_id().as_str(),
b.authority_id().as_str(),
b.action().as_str(),
b.resource().as_str(),
))
});
let next_cursor =
(offset.saturating_add(limit) < matches.len()).then(|| (offset + limit).to_string());
let page: Result<Vec<SpecTrustRecord>, String> = matches
.iter()
.skip(offset)
.take(limit)
.map(reserialize)
.collect();
match page {
Ok(records) => respond(
&doc,
RecordQueryResponse {
records,
next_cursor,
},
),
Err(reason) => Err(doc.reject_with(new_id(), RejectReason::InternalError { reason })),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
Action, AuthorityId, EntityId, RecordType, Resource, TrustRecord, TrustRecordBuilder,
};
use crate::storage::repository::{TrustRecordList, TrustRecordQuery};
use std::sync::Mutex;
use trust_tasks_rs::Payload;
#[derive(Default)]
struct MockRepo {
record: Option<TrustRecord>,
listing: Vec<TrustRecord>,
created: Mutex<Vec<TrustRecord>>,
updated: Mutex<Vec<TrustRecord>>,
create_conflicts: bool,
fail: bool,
}
fn sample_record() -> TrustRecord {
TrustRecordBuilder::new()
.entity_id(EntityId::new("did:example:entity"))
.authority_id(AuthorityId::new("did:example:authority"))
.action(Action::new("issue"))
.resource(Resource::new("vc"))
.recognized(true)
.authorized(true)
.record_type(RecordType::Authorization)
.build()
.expect("valid record")
}
#[async_trait::async_trait]
impl TrustRecordRepository for MockRepo {
async fn find_by_query(
&self,
_query: TrustRecordQuery,
) -> Result<Option<TrustRecord>, RepositoryError> {
if self.fail {
return Err(RepositoryError::QueryFailed("boom".into()));
}
Ok(self.record.clone())
}
}
#[async_trait::async_trait]
impl TrustRecordAdminRepository for MockRepo {
async fn create(&self, record: TrustRecord) -> Result<(), RepositoryError> {
if self.create_conflicts {
return Err(RepositoryError::RecordAlreadyExists("taken".into()));
}
self.created
.lock()
.map_err(|_| RepositoryError::LockPoisoned)?
.push(record);
Ok(())
}
async fn update(&self, record: TrustRecord) -> Result<(), RepositoryError> {
self.updated
.lock()
.map_err(|_| RepositoryError::LockPoisoned)?
.push(record);
Ok(())
}
async fn delete(&self, _query: TrustRecordQuery) -> Result<(), RepositoryError> {
Ok(())
}
async fn list(&self) -> Result<TrustRecordList, RepositoryError> {
Ok(TrustRecordList::new(
self.record
.clone()
.into_iter()
.chain(self.listing.iter().cloned())
.collect(),
))
}
async fn read(&self, _query: TrustRecordQuery) -> Result<TrustRecord, RepositoryError> {
self.record
.clone()
.ok_or_else(|| RepositoryError::RecordNotFound("none".into()))
}
}
fn value_doc<P: Payload>(payload: P) -> TrustTask<Value> {
let value = serde_json::to_value(payload).expect("serialises");
TrustTask::new(new_id(), P::type_uri(), value)
}
fn recognition_request(
entity_id: &str,
authority_id: &str,
action: &str,
resource: &str,
) -> RecognitionRequest {
RecognitionRequest::builder()
.entity_id(entity_id)
.authority_id(authority_id)
.action(action)
.resource(resource)
.try_into()
.expect("valid recognition request")
}
#[tokio::test]
async fn recognition_returns_typed_response() {
let repo = Arc::new(MockRepo {
record: Some(sample_record()),
..Default::default()
});
let dispatcher = build_dispatcher(repo);
let doc = value_doc(recognition_request(
"did:example:entity",
"did:example:authority",
"issue",
"vc",
));
let out = handle_document(&dispatcher, doc)
.await
.expect("ok response");
assert!(out.type_uri.is_response());
let resp: RecognitionResponse =
serde_json::from_value(out.payload).expect("response parses");
assert!(resp.recognized);
assert_eq!(resp.entity_id, "did:example:entity");
assert!(resp.message.is_some());
}
#[tokio::test]
async fn recognition_absent_record_is_not_recognized() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_dispatcher(repo);
let doc = value_doc(recognition_request("x", "y", "a", "r"));
let out = handle_document(&dispatcher, doc).await.expect("ok");
let resp: RecognitionResponse = serde_json::from_value(out.payload).expect("parses");
assert!(!resp.recognized);
assert!(resp.message.is_none());
}
#[tokio::test]
async fn put_of_a_new_key_creates_and_reports_created() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_dispatcher(repo.clone());
let doc = value_doc(RecordPutRequest {
record: reserialize(&sample_record()).expect("domain -> spec record"),
expected_existing: None,
});
let out = handle_document(&dispatcher, doc).await.expect("ok");
let ack: RecordPutResponse = serde_json::from_value(out.payload).expect("ack parses");
assert!(ack.ok);
assert!(ack.created);
assert_eq!(repo.created.lock().unwrap().len(), 1);
assert_eq!(repo.updated.lock().unwrap().len(), 0);
}
#[tokio::test]
async fn put_of_an_existing_key_falls_back_to_update() {
let repo = Arc::new(MockRepo {
create_conflicts: true,
..Default::default()
});
let dispatcher = build_dispatcher(repo.clone());
let doc = value_doc(RecordPutRequest {
record: reserialize(&sample_record()).expect("domain -> spec record"),
expected_existing: None,
});
let out = handle_document(&dispatcher, doc).await.expect("ok");
let ack: RecordPutResponse = serde_json::from_value(out.payload).expect("ack parses");
assert!(ack.ok);
assert!(!ack.created, "replacing an existing key is not a create");
assert_eq!(repo.updated.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn strict_create_put_rejects_an_existing_key() {
let repo = Arc::new(MockRepo {
create_conflicts: true,
..Default::default()
});
let dispatcher = build_dispatcher(repo.clone());
let doc = value_doc(RecordPutRequest {
record: reserialize(&sample_record()).expect("domain -> spec record"),
expected_existing: Some(false),
});
let out = handle_document(&dispatcher, doc).await;
assert!(out.is_err(), "strict create over an existing key rejects");
assert_eq!(repo.updated.lock().unwrap().len(), 0);
}
#[tokio::test]
async fn strict_update_put_routes_to_update() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_dispatcher(repo.clone());
let doc = value_doc(RecordPutRequest {
record: reserialize(&sample_record()).expect("domain -> spec record"),
expected_existing: Some(true),
});
let out = handle_document(&dispatcher, doc).await.expect("ok");
let ack: RecordPutResponse = serde_json::from_value(out.payload).expect("ack parses");
assert!(!ack.created);
assert_eq!(repo.updated.lock().unwrap().len(), 1);
assert_eq!(repo.created.lock().unwrap().len(), 0);
}
#[tokio::test]
async fn fully_keyed_query_fetches_exactly_one_record() {
let repo = Arc::new(MockRepo {
record: Some(sample_record()),
..Default::default()
});
let dispatcher = build_dispatcher(repo);
let doc = value_doc(RecordQueryRequest {
entity_id: Some("did:example:entity".into()),
authority_id: Some("did:example:authority".into()),
action: Some("issue".into()),
resource: Some("vc".into()),
..Default::default()
});
let out = handle_document(&dispatcher, doc).await.expect("ok");
let resp: RecordQueryResponse = serde_json::from_value(out.payload).expect("parses");
assert_eq!(resp.records.len(), 1);
assert!(resp.next_cursor.is_none());
}
#[tokio::test]
async fn fully_keyed_query_miss_is_an_error_not_an_empty_page() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_dispatcher(repo);
let doc = value_doc(RecordQueryRequest {
entity_id: Some("x".into()),
authority_id: Some("y".into()),
action: Some("a".into()),
resource: Some("r".into()),
..Default::default()
});
let out = handle_document(&dispatcher, doc).await;
assert!(out.is_err(), "fully keyed miss must reject with notFound");
}
fn listing_record(entity: &str) -> TrustRecord {
TrustRecordBuilder::new()
.entity_id(EntityId::new(entity))
.authority_id(AuthorityId::new("did:example:authority"))
.action(Action::new("issue"))
.resource(Resource::new("vc"))
.recognized(true)
.authorized(true)
.record_type(RecordType::Authorization)
.build()
.expect("valid record")
}
#[tokio::test]
async fn partial_query_filters_and_paginates_with_a_stable_cursor() {
let repo = Arc::new(MockRepo {
listing: vec![
listing_record("did:example:charlie"),
listing_record("did:example:alice"),
listing_record("did:example:bob"),
],
..Default::default()
});
let dispatcher = build_dispatcher(repo);
let doc = value_doc(RecordQueryRequest {
authority_id: Some("did:example:authority".into()),
limit: Some(2),
..Default::default()
});
let out = handle_document(&dispatcher, doc).await.expect("ok");
let page1: RecordQueryResponse = serde_json::from_value(out.payload).expect("parses");
assert_eq!(page1.records.len(), 2);
assert_eq!(page1.records[0].entity_id, "did:example:alice");
assert_eq!(page1.records[1].entity_id, "did:example:bob");
let cursor = page1.next_cursor.expect("a second page remains");
let doc = value_doc(RecordQueryRequest {
authority_id: Some("did:example:authority".into()),
limit: Some(2),
cursor: Some(cursor),
..Default::default()
});
let out = handle_document(&dispatcher, doc).await.expect("ok");
let page2: RecordQueryResponse = serde_json::from_value(out.payload).expect("parses");
assert_eq!(page2.records.len(), 1);
assert_eq!(page2.records[0].entity_id, "did:example:charlie");
assert!(page2.next_cursor.is_none());
}
#[tokio::test]
async fn partial_query_with_no_match_is_an_empty_page_not_an_error() {
let repo = Arc::new(MockRepo {
record: Some(sample_record()),
..Default::default()
});
let dispatcher = build_dispatcher(repo);
let doc = value_doc(RecordQueryRequest {
authority_id: Some("did:example:someone-else".into()),
..Default::default()
});
let out = handle_document(&dispatcher, doc).await.expect("ok");
let resp: RecordQueryResponse = serde_json::from_value(out.payload).expect("parses");
assert!(resp.records.is_empty());
assert!(resp.next_cursor.is_none());
}
#[tokio::test]
async fn malformed_cursor_is_rejected() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_dispatcher(repo);
let doc = value_doc(RecordQueryRequest {
cursor: Some("not-a-cursor".into()),
..Default::default()
});
let out = handle_document(&dispatcher, doc).await;
assert!(out.is_err(), "a cursor we did not mint must be rejected");
}
#[tokio::test]
async fn repository_error_becomes_error_response() {
let repo = Arc::new(MockRepo {
fail: true,
..Default::default()
});
let dispatcher = build_dispatcher(repo);
let doc = value_doc(recognition_request("x", "y", "a", "r"));
let out = handle_document(&dispatcher, doc).await;
assert!(out.is_err(), "repository failure should reject");
}
#[tokio::test]
async fn unknown_type_is_rejected() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_dispatcher(repo);
let doc = TrustTask::new(
new_id(),
"https://trusttasks.org/spec/registry/does-not-exist/0.1"
.parse()
.expect("valid type uri"),
serde_json::json!({}),
);
let out = handle_document(&dispatcher, doc).await;
assert!(
out.is_err(),
"unknown type should route to an error response"
);
}
#[test]
fn dispatcher_registers_all_five_ops() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_dispatcher(repo);
assert_eq!(dispatcher.registered_uris().len(), 5);
}
#[test]
fn query_dispatcher_registers_only_the_two_reads() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_query_dispatcher(repo);
assert_eq!(dispatcher.registered_uris().len(), 2);
}
#[tokio::test]
async fn query_dispatcher_handles_recognition() {
let repo = Arc::new(MockRepo {
record: Some(sample_record()),
..Default::default()
});
let dispatcher = build_query_dispatcher(repo);
let doc = value_doc(recognition_request(
"did:example:entity",
"did:example:authority",
"issue",
"vc",
));
let out = handle_document(&dispatcher, doc).await.expect("ok");
let resp: RecognitionResponse = serde_json::from_value(out.payload).expect("parses");
assert!(resp.recognized);
}
#[tokio::test]
async fn query_dispatcher_rejects_record_writes() {
let repo = Arc::new(MockRepo::default());
let dispatcher = build_query_dispatcher(repo);
let doc = value_doc(RecordPutRequest {
record: reserialize(&sample_record()).expect("domain -> spec record"),
expected_existing: None,
});
let out = handle_document(&dispatcher, doc).await;
assert!(
out.is_err(),
"write over the query dispatcher must be rejected"
);
}
}