use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::{Value, json};
use vti_common::error::AppError;
use crate::policy::engine::{CompiledPolicy, evaluate};
use crate::policy::model::PolicyPurpose;
use crate::server::AppState;
const DECISION_QUERY: &str = "data.vtc.groups.decision";
pub struct GroupFacts<'a> {
pub group_id: &'a str,
pub join_policy: &'a str,
pub visibility: &'a str,
pub show_members: &'a str,
pub issues_credentials: bool,
pub member_count: usize,
}
pub struct Facts<'a> {
pub operation: &'static str,
pub now: DateTime<Utc>,
pub actor_did: &'a str,
pub actor_role: Option<&'a str>,
pub actor_member: bool,
pub actor_manager: bool,
pub group: GroupFacts<'a>,
}
#[derive(Debug, Deserialize)]
struct Decision {
effect: String,
#[serde(default)]
with: DecisionWith,
}
#[derive(Debug, Default, Deserialize)]
struct DecisionWith {
#[serde(default)]
code: Option<String>,
#[serde(default)]
reason: Option<String>,
}
pub fn judge(policy: &CompiledPolicy, facts: &Facts<'_>) -> Result<Option<String>, AppError> {
let input = json!({
"operation": facts.operation,
"now": facts.now,
"actor": {
"did": facts.actor_did,
"role": facts.actor_role,
"member": facts.actor_member,
"manager": facts.actor_manager,
},
"group": {
"groupId": facts.group.group_id,
"kind": "static",
"joinPolicy": facts.group.join_policy,
"visibility": facts.group.visibility,
"showMembers": facts.group.show_members,
"issuesCredentials": facts.group.issues_credentials,
"memberCount": facts.group.member_count,
},
});
let results = match evaluate(policy, DECISION_QUERY, input) {
Ok(r) => r,
Err(e @ AppError::ResourceExhausted(_)) => {
tracing::warn!(error = %e, "groups policy hit a resource bound; refusing");
return Ok(Some(
"the community's groups policy could not decide".into(),
));
}
Err(e) => return Err(e),
};
let value = results.pointer("/result/0/expressions/0/value");
let Some(value) = value.filter(|v| !matches!(v, Value::Object(o) if o.is_empty())) else {
return Ok(Some(
"the community's groups policy answered nothing".into(),
));
};
let decision: Decision = serde_json::from_value(value.clone()).map_err(|e| {
AppError::Internal(format!(
"the active groups policy returned a decision this service cannot read: {e}"
))
})?;
match decision.effect.as_str() {
"allow" => Ok(None),
_ => Ok(Some(
decision
.with
.reason
.or(decision.with.code)
.unwrap_or_else(|| "refused by the community's groups policy".into()),
)),
}
}
pub async fn decide(state: &AppState, facts: &Facts<'_>) -> Result<Option<String>, AppError> {
let policy = crate::policy::load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::Groups,
)
.await?;
judge(&policy, facts)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::engine::compile;
fn facts(operation: &'static str, member_count: usize) -> Facts<'static> {
Facts {
operation,
now: Utc::now(),
actor_did: "did:example:carol",
actor_role: Some("member"),
actor_member: true,
actor_manager: false,
group: GroupFacts {
group_id: "grp_AAAAAAAAAAAAAAAAAAAAAA",
join_policy: "open",
visibility: "members",
show_members: "members",
issues_credentials: true,
member_count,
},
}
}
#[test]
fn the_shipped_default_allows() {
let p = compile(
crate::policy::default::default_source(PolicyPurpose::Groups),
uuid::Uuid::nil(),
)
.unwrap();
assert_eq!(judge(&p, &facts("join", 0)).unwrap(), None);
assert_eq!(judge(&p, &facts("create", 0)).unwrap(), None);
}
#[test]
fn a_policy_can_cap_a_group() {
let p = compile(
r#"package vtc.groups
import rego.v1
default decision := {"effect": "allow"}
decision := {"effect": "deny", "with": {"code": "group-full", "reason": "this group is full"}} if {
input.operation in {"join", "request"}
input.group.memberCount >= 2
}
"#,
uuid::Uuid::nil(),
)
.unwrap();
assert_eq!(judge(&p, &facts("join", 1)).unwrap(), None);
assert_eq!(
judge(&p, &facts("join", 2)).unwrap().as_deref(),
Some("this group is full")
);
}
#[test]
fn a_policy_that_answers_nothing_refuses() {
let p = compile("package vtc.groups\nimport rego.v1\n", uuid::Uuid::nil()).unwrap();
assert!(judge(&p, &facts("create", 0)).unwrap().is_some());
}
}