use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::policy::engine::{CompiledPolicy, evaluate};
use crate::policy::model::PolicyPurpose;
use crate::server::AppState;
use vti_common::error::AppError;
use super::rules::{RuleSettings, RulesPassed};
const DECISION_QUERY: &str = "data.vtc.git_namespace.decision";
const SETTINGS_QUERY: &str = "data.vtc.git_namespace.settings";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Party {
pub did: String,
pub member: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default)]
pub rights: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Capabilities {
pub bridge: bool,
pub bot_can_create_repos: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bridge_did: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitNsFacts {
pub now: DateTime<Utc>,
pub action: String,
pub actor: Party,
pub resource: String,
pub forge: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub right: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<Party>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
pub capabilities: Capabilities,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<String>,
}
#[derive(Debug, Clone)]
pub struct VerifiedGitNsFacts(GitNsFacts);
impl VerifiedGitNsFacts {
pub fn after_fixed_rules(facts: GitNsFacts, _passed: RulesPassed) -> Result<Self, AppError> {
if facts.actor.did.trim().is_empty() {
return Err(AppError::Forbidden(
"a git-namespace request must name the party that signed it".into(),
));
}
Ok(Self(facts))
}
pub fn facts(&self) -> &GitNsFacts {
&self.0
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Settings {
pub rules: RuleSettings,
pub cascade_on_departure: bool,
pub enforce_role_drift: bool,
pub break_glass: BreakGlassSettings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BreakGlassSettings {
pub enabled: bool,
pub delay_seconds: u64,
pub min_justification_chars: usize,
}
impl Default for BreakGlassSettings {
fn default() -> Self {
Self {
enabled: true,
delay_seconds: 0,
min_justification_chars: 0,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
struct SettingsDoc {
#[serde(default)]
maintainer_grants_commit: bool,
#[serde(default)]
cascade_on_departure: bool,
#[serde(default)]
role_drift: Option<String>,
#[serde(default)]
break_glass: Option<String>,
#[serde(default)]
break_glass_delay_seconds: Option<u64>,
#[serde(default)]
break_glass_min_justification_chars: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
struct Decision {
effect: String,
#[serde(default)]
with: DecisionWith,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct DecisionWith {
#[serde(default)]
code: Option<String>,
#[serde(default)]
reason: Option<String>,
}
pub struct ActivePolicy {
pub compiled: CompiledPolicy,
pub version: Option<u32>,
}
pub async fn load(state: &AppState) -> Result<ActivePolicy, AppError> {
let compiled = crate::policy::load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::GitNamespace,
)
.await
.map_err(|e| {
tracing::warn!(error = %e, "no active git-namespace policy; refusing");
AppError::Forbidden(
"git-namespace request denied (no-policy): this community has no active \
`gitNamespace` policy. The shipped default installs at boot; check \
`GET /v1/policies`."
.into(),
)
})?;
let version = active_version(state).await;
Ok(ActivePolicy { compiled, version })
}
async fn active_version(state: &AppState) -> Option<u32> {
let id = crate::policy::storage::get_active_policy_id(
&state.active_policies_ks,
PolicyPurpose::GitNamespace,
)
.await
.ok()
.flatten()?;
crate::policy::storage::get_policy(&state.policies_ks, id)
.await
.ok()
.flatten()
.map(|p| p.version)
}
fn first_value(results: &Value) -> Option<&Value> {
results
.get("result")
.and_then(|r| r.as_array())
.and_then(|rows| rows.first())
.and_then(|row| row.get("expressions"))
.and_then(|e| e.as_array())
.and_then(|exprs| exprs.first())
.and_then(|expr| expr.get("value"))
}
pub fn settings(policy: &CompiledPolicy) -> Settings {
let Ok(results) = evaluate(policy, SETTINGS_QUERY, serde_json::json!({})) else {
return Settings::default();
};
let Some(value) = first_value(&results) else {
return Settings::default();
};
let doc: SettingsDoc = serde_json::from_value(value.clone()).unwrap_or_default();
Settings {
rules: RuleSettings {
maintainer_grants_commit: doc.maintainer_grants_commit,
},
cascade_on_departure: doc.cascade_on_departure,
enforce_role_drift: doc.role_drift.as_deref() == Some("enforce"),
break_glass: BreakGlassSettings {
enabled: doc.break_glass.as_deref() != Some("disabled"),
delay_seconds: doc.break_glass_delay_seconds.unwrap_or(0).min(86_400),
min_justification_chars: doc.break_glass_min_justification_chars.unwrap_or(0),
},
}
}
pub fn pr_gate(policy: &CompiledPolicy) -> super::pr_gate::PrGateSettings {
let value = evaluate(policy, SETTINGS_QUERY, serde_json::json!({}))
.ok()
.and_then(|r| first_value(&r).cloned());
let (settings, problems) = super::pr_gate::PrGateSettings::from_settings(value.as_ref());
for problem in problems {
tracing::warn!(
%problem,
"the gitNamespace policy's pull-request settings are partly unreadable; that key \
keeps its default"
);
}
settings
}
pub async fn active_pr_gate(state: &AppState) -> super::pr_gate::PrGateSettings {
match load(state).await {
Ok(p) => pr_gate(&p.compiled),
Err(_) => super::pr_gate::PrGateSettings::default(),
}
}
pub async fn active_settings(state: &AppState) -> Settings {
match load(state).await {
Ok(p) => settings(&p.compiled),
Err(_) => Settings::default(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyDenied {
pub code: String,
pub message: String,
}
pub fn decide(verified: &VerifiedGitNsFacts, policy: &CompiledPolicy) -> Result<(), PolicyDenied> {
let input = serde_json::to_value(verified.facts()).map_err(|e| PolicyDenied {
code: "unreadable-input".into(),
message: format!("the request could not be put to the policy: {e}"),
})?;
let results = evaluate(policy, DECISION_QUERY, input).map_err(|e| PolicyDenied {
code: "evaluation-failed".into(),
message: format!("the git-namespace policy failed to evaluate: {e}"),
})?;
let Some(value) = first_value(&results) else {
return Err(PolicyDenied {
code: "no-decision".into(),
message: "the active git-namespace policy answered nothing, which is refused \
rather than read as consent"
.into(),
});
};
let decision: Decision = serde_json::from_value(value.clone()).map_err(|e| PolicyDenied {
code: "unreadable-decision".into(),
message: format!("the git-namespace policy returned a decision this VTC cannot read: {e}"),
})?;
match decision.effect.as_str() {
"allow" => Ok(()),
"deny" => {
let code = decision.with.code.unwrap_or_else(|| "denied".into());
let message = match decision.with.reason {
Some(r) => format!("git-namespace policy refused ({code}): {r}"),
None => format!("git-namespace policy refused ({code})"),
};
Err(PolicyDenied { code, message })
}
other => Err(PolicyDenied {
code: "unsupported-verdict".into(),
message: format!(
"the git-namespace policy answered `{other}`, which a git-namespace request \
cannot carry out"
),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::default::default_source;
use crate::policy::engine::compile;
fn default_policy() -> CompiledPolicy {
compile(
default_source(PolicyPurpose::GitNamespace),
uuid::Uuid::nil(),
)
.expect("the bundled git_ns policy compiles")
}
fn party(did: &str, member: bool) -> Party {
Party {
did: did.into(),
member,
role: member.then(|| "member".to_string()),
rights: vec![],
}
}
fn facts(action: &str, actor_member: bool, subject: Option<Party>) -> VerifiedGitNsFacts {
VerifiedGitNsFacts::after_fixed_rules(
GitNsFacts {
now: "2026-09-23T12:00:00Z".parse().unwrap(),
action: action.into(),
actor: party("did:key:actor", actor_member),
resource: "github.com/acme/widgets".into(),
forge: "github.com".into(),
right: Some("git.commit.sign".into()),
subject,
visibility: None,
expires_at: None,
capabilities: Capabilities::default(),
via: None,
},
RulesPassed::for_test(),
)
.unwrap()
}
#[test]
fn the_default_admits_members_granting_to_members() {
let f = facts("right.grant", true, Some(party("did:key:s", true)));
assert_eq!(decide(&f, &default_policy()), Ok(()));
}
#[test]
fn the_default_refuses_external_signers() {
let f = facts("right.grant", true, Some(party("did:key:s", false)));
let err = decide(&f, &default_policy()).unwrap_err();
assert_eq!(err.code, "external-signers-not-enabled");
}
#[test]
fn the_default_refuses_a_non_member_actor() {
let f = facts("repo.create", false, None);
assert!(decide(&f, &default_policy()).is_err());
}
#[test]
fn a_resignation_or_revocation_is_never_refused_for_a_non_member_subject() {
let f = facts("right.revoke", true, Some(party("did:key:s", false)));
assert_eq!(decide(&f, &default_policy()), Ok(()));
}
fn service_grant(
subject: &str,
bridge: &str,
resource: &str,
right: &str,
) -> VerifiedGitNsFacts {
VerifiedGitNsFacts::after_fixed_rules(
GitNsFacts {
now: "2026-09-23T12:00:00Z".parse().unwrap(),
action: "bridge.serviceGrant".into(),
actor: party("did:webvh:vtc", true),
resource: resource.into(),
forge: "github.com".into(),
right: Some(right.into()),
subject: Some(party(subject, false)),
visibility: None,
expires_at: None,
capabilities: Capabilities {
bridge: true,
bridge_did: Some(bridge.into()),
..Capabilities::default()
},
via: None,
},
RulesPassed::for_test(),
)
.unwrap()
}
#[test]
fn the_default_admits_exactly_the_namespaces_own_bridge_service_grant() {
let p = default_policy();
let b = "did:key:bridge";
assert_eq!(
decide(
&service_grant(b, b, "github.com/acme", "git.commit.sign"),
&p
),
Ok(())
);
assert!(
decide(
&service_grant("did:key:x", b, "github.com/acme", "git.commit.sign"),
&p
)
.is_err()
);
assert!(
decide(
&service_grant(b, b, "github.com/acme/w", "git.commit.sign"),
&p
)
.is_err()
);
assert!(decide(&service_grant(b, b, "github.com/acme", "git.ns.admin"), &p).is_err());
}
#[test]
fn the_default_settings_are_all_off() {
assert_eq!(settings(&default_policy()), Settings::default());
}
#[test]
fn the_default_pull_request_gate_is_anyone() {
let gate = pr_gate(&default_policy());
assert_eq!(gate, crate::git_ns::pr_gate::PrGateSettings::default());
assert!(!gate.is_configured());
}
#[test]
fn pull_request_settings_are_read_from_the_policy() {
let p = compile(
"package vtc.git_namespace\nimport rego.v1\n\
default decision := {\"effect\": \"allow\"}\n\
settings := {\"pr_open\": {\"roles\": [\"moderator\"]}, \
\"pr_open_overrides\": {\"github.com/acme/docs\": \"anyone\"}, \
\"pr_exempt\": []}\n",
uuid::Uuid::nil(),
)
.unwrap();
let gate = pr_gate(&p);
assert_eq!(
gate.pr_open,
crate::git_ns::pr_gate::PrOpenLevel::Roles(vec!["moderator".into()])
);
assert!(gate.exempt.is_empty());
let docs = super::super::model::Resource::parse("github.com/acme/docs").unwrap();
assert_eq!(
gate.level_for(&docs),
&crate::git_ns::pr_gate::PrOpenLevel::Anyone
);
assert_eq!(settings(&p), Settings::default());
}
#[test]
fn a_policy_answering_nothing_is_a_refusal() {
let p = compile(
"package vtc.git_namespace\nimport rego.v1\n",
uuid::Uuid::nil(),
)
.unwrap();
let f = facts("right.grant", true, Some(party("did:key:s", true)));
assert_eq!(decide(&f, &p).unwrap_err().code, "no-decision");
}
#[test]
fn settings_are_read_from_the_policy() {
let p = compile(
"package vtc.git_namespace\nimport rego.v1\n\
default decision := {\"effect\": \"allow\"}\n\
settings := {\"maintainer_grants_commit\": true, \"cascade_on_departure\": true, \
\"role_drift\": \"enforce\"}\n",
uuid::Uuid::nil(),
)
.unwrap();
let s = settings(&p);
assert!(s.rules.maintainer_grants_commit);
assert!(s.cascade_on_departure);
assert!(s.enforce_role_drift);
}
}