use std::sync::Arc;
use super::harness::{Absent, Journal, caller, other, two_tenant_state};
use crate::admin::auth::{AdminAction, AdminGrant, AdminIdentity};
use crate::admin::error::AdminError;
use crate::admin::protocol::{AuditSummary, MutationPreconditions, MutationRequest, WriteMode};
use crate::admin::reads::HistoryRequest;
use crate::admin::service::{AdminService, DesiredStateEdit, MutationResult};
use crate::desired_state::access::DenialPage;
use crate::desired_state::{
DesiredState, ExpectedRevision, IdempotencyKey, MutationKind, ResourceScope, RevisionId,
Surface, fixtures,
};
const ISSUER: &str = "https://idp.example";
const SUBJECT: &str = "alice";
fn identity() -> AdminIdentity {
AdminIdentity::Human {
issuer: ISSUER.to_owned(),
subject: SUBJECT.to_owned(),
}
}
fn grant(action: AdminAction, scope: ResourceScope) -> AdminGrant {
AdminGrant::granted(identity(), action, scope)
}
fn request(key: &str, expected: ExpectedRevision, scope: ResourceScope) -> MutationRequest {
MutationRequest {
preconditions: MutationPreconditions {
expected,
idempotency_key: IdempotencyKey::parse(key).expect("a valid key"),
mode: WriteMode::Apply,
},
kind: MutationKind::Update,
surface: Surface::Tenant,
scope,
summary: AuditSummary::parse("publish two tenants").expect("a valid summary"),
}
}
fn replace_with(state: DesiredState) -> impl DesiredStateEdit {
move |target: &mut DesiredState| {
*target = state.clone();
Ok(())
}
}
async fn published(journal: &Journal) -> (AdminService, RevisionId) {
let service = AdminService::stateful(journal.store());
let outcome = service
.apply(
&grant(AdminAction::Publish, ResourceScope::Deployment),
&request(
"two-tenants",
ExpectedRevision::Empty,
ResourceScope::Deployment,
),
&replace_with(two_tenant_state()),
)
.await
.expect("a deployment-scoped publication of two tenants");
let revision = RevisionId::parse(outcome.revision().expect("a published revision"))
.expect("a revision id");
(service, revision)
}
async fn other_tenants_rows(journal: &Journal) -> Vec<String> {
journal
.stored(&format!(
"SELECT t::text FROM axond_cp_resource_version t WHERE tenant_id = '{}' \
ORDER BY resource_kind, resource_id, version",
other()
))
.await
}
#[tokio::test]
async fn a_tenant_scoped_administrator_cannot_publish_into_another_tenant() {
let Some(journal) = Journal::open().await else {
return;
};
let (service, revision) = published(&journal).await;
let before = other_tenants_rows(&journal).await;
assert!(
!before.is_empty(),
"the other tenant has no durable rows, so nothing here is protecting anything"
);
let absent = Absent::of_the_other_tenant();
let mine = ResourceScope::Tenant(caller());
let claimed = service
.apply(
&grant(AdminAction::Publish, mine.clone()),
&request(
"claim-theirs",
ExpectedRevision::Exactly(revision),
ResourceScope::Tenant(other()),
),
&replace_with(two_tenant_state()),
)
.await
.expect_err("a grant for one tenant does not publish as another");
let mut seized = two_tenant_state();
seized
.insert(fixtures::credential(&other(), 15, "seized"))
.expect("a state that reaches into the other tenant is constructible");
let reached = service
.apply(
&grant(AdminAction::Publish, mine.clone()),
&request("reach-theirs", ExpectedRevision::Exactly(revision), mine),
&replace_with(seized),
)
.await
.expect_err("a tenant-scoped grant does not add another tenant's credential");
for (surface, error) in [
("a claimed scope", &claimed),
("an out-of-scope edit", &reached),
] {
assert_eq!(error.code(), "admin_forbidden", "{surface}: {error}");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
let envelope = serde_json::to_string(&error.envelope()).expect("a serialisable envelope");
absent.assert_absent(&format!("the refusal of {surface}"), &envelope);
}
assert_eq!(
journal.head().await,
Some(revision),
"a refused mutation moved the head"
);
assert_eq!(
other_tenants_rows(&journal).await,
before,
"a refused mutation changed the other tenant's rows"
);
let store = journal.store();
let filed = |scope: ResourceScope| {
let store = Arc::clone(&store);
async move {
let tenant = match scope {
ResourceScope::Tenant(tenant) => tenant,
other => panic!("a denial read is per tenant, not {other:?}"),
};
store
.denials(&DenialPage::for_scope(Some(tenant)), 10)
.await
.expect("the denial trail")
}
};
let theirs = filed(ResourceScope::Tenant(other())).await;
let ours = filed(ResourceScope::Tenant(caller())).await;
assert_eq!(theirs.len(), 1, "the probe is not filed once: {theirs:?}");
assert_eq!(ours.len(), 1, "the reach is not filed once: {ours:?}");
assert_eq!(theirs[0].scope, ResourceScope::Tenant(other()));
assert_eq!(ours[0].scope, ResourceScope::Tenant(caller()));
for denial in [&theirs[0], &ours[0]] {
assert_eq!(denial.surface, Surface::Tenant);
assert_eq!(denial.actor, identity().actor());
}
assert!(
store
.denials(&DenialPage::for_scope(None), 10)
.await
.expect("the deployment-scoped denial trail")
.is_empty(),
"a tenant's refusal was filed as deployment-wide state"
);
}
#[tokio::test]
async fn a_cross_tenant_reference_names_the_caller_and_not_what_it_reached_for() {
let Some(journal) = Journal::open().await else {
return;
};
let (service, revision) = published(&journal).await;
let before = other_tenants_rows(&journal).await;
let theirs = fixtures::credential(&other(), 13, "secondary");
let mut state = two_tenant_state();
state
.insert(fixtures::alias(
&caller(),
16,
"borrowed",
&[theirs.reference],
))
.expect("an alias that points across a tenant boundary is constructible");
let error = service
.apply(
&grant(AdminAction::Publish, ResourceScope::Deployment),
&request(
"borrow-theirs",
ExpectedRevision::Exactly(revision),
ResourceScope::Deployment,
),
&replace_with(state),
)
.await
.expect_err("an alias may not depend on another tenant's credential");
assert_eq!(error.code(), "validation_failed");
assert_eq!(error.rule(), Some("cross_tenant_reference"));
assert_eq!(
error.reference(),
Some(fixtures::reference(
crate::desired_state::ResourceKind::Alias,
16
)),
"the refusal names the caller's own alias"
);
let envelope = serde_json::to_string(&error.envelope()).expect("a serialisable envelope");
Absent::of_the_other_tenant().assert_absent("the validation refusal", &envelope);
let detail = error
.operator_detail()
.expect("a validation refusal carries an operator detail");
assert!(
detail.contains(&theirs.reference.id.to_string()),
"the refusal is not about the credential it was supposed to be about: {detail}"
);
assert_eq!(journal.head().await, Some(revision));
assert_eq!(other_tenants_rows(&journal).await, before);
}
#[tokio::test]
async fn a_tenant_scoped_administrator_reads_no_deployment_wide_projection() {
let Some(journal) = Journal::open().await else {
return;
};
let (service, revision) = published(&journal).await;
let mine = ResourceScope::Tenant(caller());
let scoped = |action| grant(action, mine.clone());
let absent = Absent::of_the_other_tenant();
for (surface, error) in [
(
"the desired state",
service
.desired_state(&scoped(AdminAction::ReadState))
.await
.expect_err("deployment-wide state needs deployment authority"),
),
(
"the revision history",
service
.history(&scoped(AdminAction::ReadHistory), HistoryRequest::default())
.await
.expect_err("deployment-wide history needs deployment authority"),
),
(
"the audit trail",
service
.audit(&scoped(AdminAction::ReadAudit), revision)
.await
.expect_err("a deployment-wide audit trail needs deployment authority"),
),
] {
assert_eq!(error.code(), "admin_forbidden", "{surface}: {error}");
assert!(
matches!(error, AdminError::Forbidden(_)),
"{surface} was refused for the wrong reason: {error:?}"
);
let envelope = serde_json::to_string(&error.envelope()).expect("a serialisable envelope");
absent.assert_absent(&format!("the refusal of {surface}"), &envelope);
}
let view = service
.desired_state(&grant(AdminAction::ReadState, ResourceScope::Deployment))
.await
.expect("deployment authority reads the deployment's state");
let rendered = serde_json::to_string(&view).expect("a serialisable state view");
for (label, id) in [
("tenant id", other().to_string()),
("credential id", fixtures::resource_id(13).to_string()),
] {
assert!(
rendered.contains(&id),
"the deployment-wide projection does not carry the other tenant's {label}, \
so refusing it protects nothing"
);
}
}
#[tokio::test]
async fn a_rehearsed_cross_tenant_mutation_is_refused_like_a_real_one() {
let Some(journal) = Journal::open().await else {
return;
};
let (service, revision) = published(&journal).await;
let mut rehearsal = request(
"rehearse-theirs",
ExpectedRevision::Exactly(revision),
ResourceScope::Tenant(other()),
);
rehearsal.preconditions.mode = WriteMode::DryRun;
let error = service
.apply(
&grant(AdminAction::Publish, ResourceScope::Tenant(caller())),
&rehearsal,
&replace_with(two_tenant_state()),
)
.await
.expect_err("a rehearsal is not a way to read another tenant's state");
assert_eq!(error.code(), "admin_forbidden");
assert_eq!(journal.head().await, Some(revision));
let outcome = service
.apply(
&grant(AdminAction::Publish, ResourceScope::Tenant(caller())),
&{
let mut own = request(
"rehearse-mine",
ExpectedRevision::Exactly(revision),
ResourceScope::Tenant(caller()),
);
own.preconditions.mode = WriteMode::DryRun;
own
},
&replace_with(two_tenant_state()),
)
.await
.expect("a rehearsal of unchanged state within authority");
assert!(
matches!(outcome.result, MutationResult::DryRun),
"a dry run published something: {outcome:?}"
);
assert_eq!(journal.head().await, Some(revision));
}