use vta_policy::approvals;
use vta_policy::storage;
use vta_policy::types::PolicyModule;
use vta_sdk::protocols::policy_management::{
DeletePolicyResultBody, GetPolicyResultBody, ListPoliciesResultBody, PolicyModuleView,
UpsertPolicyBody, UpsertPolicyResultBody,
};
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
const DEFAULT_PAGE_SIZE: usize = 50;
const MAX_PAGE_SIZE: usize = 200;
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
fn view(row: PolicyModule) -> PolicyModuleView {
PolicyModuleView {
id: row.id,
name: row.name,
description: row.description,
module: row.module,
applies_to: row.applies_to,
priority: row.priority,
enabled: row.enabled,
version: row.version,
created_at: row.created_at,
updated_at: row.updated_at,
ext: row.ext,
}
}
pub async fn list_policies(
policy_ks: &KeyspaceHandle,
auth: &AuthClaims,
context_id: Option<&str>,
enabled_only: bool,
page_size: Option<u64>,
channel: &str,
) -> Result<ListPoliciesResultBody, AppError> {
auth.require_manage()?;
let mut rows = storage::list_policies(policy_ks).await?;
rows.sort_by(|a, b| b.priority.cmp(&a.priority).then_with(|| a.id.cmp(&b.id)));
let mut matching: Vec<PolicyModule> = rows
.into_iter()
.filter(|r| !enabled_only || r.enabled)
.filter(|r| match context_id {
Some(ctx) => r.applies_to.is_empty() || r.applies_to.iter().any(|c| c == ctx),
None => true,
})
.collect();
let limit = page_size
.map(|n| (n as usize).clamp(1, MAX_PAGE_SIZE))
.unwrap_or(DEFAULT_PAGE_SIZE);
let truncated = matching.len() > limit;
matching.truncate(limit);
tracing::info!(
channel,
caller = %auth.did,
count = matching.len(),
truncated,
"policy list"
);
Ok(ListPoliciesResultBody {
policies: matching.into_iter().map(view).collect(),
truncated,
cursor: None,
})
}
pub async fn get_policy(
policy_ks: &KeyspaceHandle,
auth: &AuthClaims,
id: &str,
channel: &str,
) -> Result<GetPolicyResultBody, AppError> {
auth.require_manage()?;
let row = storage::get_policy(policy_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy `{id}` not found")))?;
tracing::info!(channel, caller = %auth.did, policy = id, "policy get");
Ok(GetPolicyResultBody { policy: view(row) })
}
pub async fn upsert_policy(
policy_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
req: UpsertPolicyBody,
channel: &str,
) -> Result<UpsertPolicyResultBody, AppError> {
auth.require_super_admin()?;
let id = req
.id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
vta_policy::compile(&req.module, &id)
.map_err(|e| AppError::Validation(format!("policy `{id}` does not compile: {e}")))?;
let declares = approvals::is_declarative(&req.ext);
let is_reserved = id == vta_sdk::approvals::DECLARATIVE_POLICY_ID;
match (is_reserved, declares) {
(true, true) => {
approvals::verify_declarative_row(&req.ext, &req.module)?;
}
(true, false) => {
return Err(AppError::Validation(format!(
"policy id `{id}` is reserved for the declarative approvals model and must carry \
its rules in ext[\"{}\"]. Manage it with `pnm approvals`, or use a different id \
for hand-authored Rego.",
vta_sdk::approvals::EXT_KEY_RULES,
)));
}
(false, true) => {
return Err(AppError::Validation(format!(
"only the reserved policy id `{}` may carry ext[\"{}\"]; a second declarative row \
would make it ambiguous which rules are in force",
vta_sdk::approvals::DECLARATIVE_POLICY_ID,
vta_sdk::approvals::EXT_KEY_RULES,
)));
}
(false, false) => {}
}
let existing = storage::get_policy(policy_ks, &id).await?;
if let Some(expected) = req.expected_version {
let current = existing.as_ref().map_or(0, |r| r.version);
if expected != current {
return Err(AppError::Conflict(format!(
"policy `{id}` is at version {current}, not the expected {expected} — it changed \
since you read it. Re-read it and re-apply your change."
)));
}
}
let now = now_rfc3339();
let created = existing.is_none();
let row = PolicyModule {
id: id.clone(),
name: req.name,
description: req.description,
module: req.module,
applies_to: req.applies_to,
priority: req.priority.unwrap_or(0),
enabled: req.enabled,
version: existing.as_ref().map_or(1, |r| r.version + 1),
created_at: existing
.as_ref()
.map_or_else(|| now.clone(), |r| r.created_at.clone()),
updated_at: now,
ext: req.ext,
};
storage::store_policy(policy_ks, &row).await?;
crate::audit::record(
audit,
"policy.upsert",
&auth.did,
Some(&id),
"success",
Some(channel),
None,
)
.await
.ok();
tracing::info!(
channel, caller = %auth.did, policy = %id, version = row.version, created,
"policy upserted"
);
Ok(UpsertPolicyResultBody {
policy: view(row),
created,
})
}
pub async fn delete_policy(
policy_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
id: &str,
expected_version: Option<u64>,
reason: Option<&str>,
channel: &str,
) -> Result<DeletePolicyResultBody, AppError> {
auth.require_super_admin()?;
if id == vta_policy::defaults::DEFAULT_POLICY_ID {
return Err(AppError::Validation(format!(
"`{id}` is the baseline every unmatched task falls through to; deleting it would \
make the PDP deny them all once enforcement is on, and it is not reinstalled while \
other policies exist. Disable it (`enabled: false`) if you mean to stop it firing."
)));
}
let existing = storage::get_policy(policy_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy `{id}` not found")))?;
if let Some(expected) = expected_version
&& expected != existing.version
{
return Err(AppError::Conflict(format!(
"policy `{id}` is at version {}, not the expected {expected}",
existing.version
)));
}
let deleted_at = now_rfc3339();
storage::delete_policy(policy_ks, id).await?;
crate::audit::record_with_detail(
audit,
"policy.delete",
&auth.did,
Some(id),
"success",
Some(channel),
None,
reason,
)
.await
.ok();
tracing::info!(channel, caller = %auth.did, policy = id, "policy deleted");
Ok(DeletePolicyResultBody {
id: id.to_string(),
deleted_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::Role;
use crate::store::Store;
use vta_sdk::approvals::{ApprovalRule, DECLARATIVE_POLICY_ID, synthesize_rego};
use vti_common::config::StoreConfig;
const ACL_GRANT: &str = "https://trusttasks.org/spec/acl/grant/0.1";
const HAND_REGO: &str =
"package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"allow\"}";
async fn keyspaces() -> (
KeyspaceHandle,
vta_audit::SharedAuditSink,
tempfile::TempDir,
) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
(
store.keyspace(vta_keyspaces::POLICY).unwrap(),
std::sync::Arc::new(vta_audit::KeyspaceAuditSink::new(
store.keyspace(vta_keyspaces::AUDIT).unwrap(),
)),
dir,
)
}
fn super_admin() -> AuthClaims {
AuthClaims {
did: "did:key:zSuperAdmin".into(),
role: Role::Admin,
allowed_contexts: Vec::new(),
session_id: "test-session".into(),
access_expires_at: 0,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn admin_only() -> AuthClaims {
AuthClaims {
allowed_contexts: vec!["ctx-a".into()],
..super_admin()
}
}
fn declarative_body(rules: &[ApprovalRule], module: Option<&str>) -> UpsertPolicyBody {
UpsertPolicyBody {
id: Some(DECLARATIVE_POLICY_ID.into()),
name: "Declarative approvals".into(),
description: None,
module: module.map_or_else(|| synthesize_rego(rules), str::to_string),
applies_to: vec![],
priority: Some(vta_sdk::approvals::DECLARATIVE_POLICY_PRIORITY),
enabled: true,
expected_version: None,
ext: serde_json::json!({
vta_sdk::approvals::EXT_KEY_RULES: rules,
vta_sdk::approvals::EXT_KEY_APPROVER_SETS: {},
}),
}
}
#[tokio::test]
async fn a_declarative_row_whose_module_matches_its_rules_is_accepted() {
let (policy_ks, audit, _d) = keyspaces().await;
let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
let out = upsert_policy(
&policy_ks,
&audit,
&super_admin(),
declarative_body(&rules, None),
"test",
)
.await
.expect("matching row accepted");
assert!(out.created);
assert_eq!(out.policy.version, 1);
}
#[tokio::test]
async fn a_declarative_row_whose_module_contradicts_its_rules_is_refused() {
let (policy_ks, audit, _d) = keyspaces().await;
let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
let err = upsert_policy(
&policy_ks,
&audit,
&super_admin(),
declarative_body(&rules, Some(HAND_REGO)),
"test",
)
.await
.expect_err("module/rules mismatch must be refused");
assert!(
matches!(err, AppError::Validation(ref s) if s.contains("synthesizes to")),
"got {err:?}"
);
}
#[tokio::test]
async fn the_reserved_id_cannot_hold_hand_authored_rego() {
let (policy_ks, audit, _d) = keyspaces().await;
let err = upsert_policy(
&policy_ks,
&audit,
&super_admin(),
UpsertPolicyBody {
ext: serde_json::Value::Null,
..declarative_body(&[], Some(HAND_REGO))
},
"test",
)
.await
.expect_err("reserved id without declarative ext must be refused");
assert!(
matches!(err, AppError::Validation(ref s) if s.contains("reserved")),
"got {err:?}"
);
}
#[tokio::test]
async fn only_the_reserved_id_may_carry_declarative_ext() {
let (policy_ks, audit, _d) = keyspaces().await;
let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
let err = upsert_policy(
&policy_ks,
&audit,
&super_admin(),
UpsertPolicyBody {
id: Some("impostor".into()),
..declarative_body(&rules, None)
},
"test",
)
.await
.expect_err("a non-reserved row carrying the rules ext must be refused");
assert!(
matches!(err, AppError::Validation(ref s) if s.contains("reserved policy id")),
"got {err:?}"
);
}
#[tokio::test]
async fn a_module_that_does_not_compile_is_refused() {
let (policy_ks, audit, _d) = keyspaces().await;
let err = upsert_policy(
&policy_ks,
&audit,
&super_admin(),
UpsertPolicyBody {
id: Some("broken".into()),
ext: serde_json::Value::Null,
..declarative_body(&[], Some("this is not rego {{{"))
},
"test",
)
.await
.expect_err("uncompilable Rego must not seat");
assert!(
matches!(err, AppError::Validation(ref s) if s.contains("does not compile")),
"got {err:?}"
);
}
#[tokio::test]
async fn writing_policy_is_super_admin_only() {
let (policy_ks, audit, _d) = keyspaces().await;
let err = upsert_policy(
&policy_ks,
&audit,
&admin_only(),
declarative_body(&[ApprovalRule::reauth(ACL_GRANT)], None),
"test",
)
.await
.expect_err("a context-scoped admin must not write policy");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn a_stale_expected_version_conflicts() {
let (policy_ks, audit, _d) = keyspaces().await;
let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
upsert_policy(
&policy_ks,
&audit,
&super_admin(),
declarative_body(&rules, None),
"test",
)
.await
.unwrap();
let err = upsert_policy(
&policy_ks,
&audit,
&super_admin(),
UpsertPolicyBody {
expected_version: Some(0),
..declarative_body(&rules, None)
},
"test",
)
.await
.expect_err("a stale version must conflict, not silently overwrite");
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
}
#[tokio::test]
async fn the_baseline_cannot_be_deleted() {
let (policy_ks, audit, _d) = keyspaces().await;
vta_policy::install_default_policy(&policy_ks, "2026-08-09T00:00:00Z")
.await
.unwrap();
let err = delete_policy(
&policy_ks,
&audit,
&super_admin(),
vta_policy::defaults::DEFAULT_POLICY_ID,
None,
None,
"test",
)
.await
.expect_err("the baseline must not be deletable");
assert!(
matches!(err, AppError::Validation(ref s) if s.contains("baseline")),
"got {err:?}"
);
}
}