jouleclaw: bind a policy receipt to the rules, not to a version number #177
10 changed files with 728 additions and 6 deletions
|
|
@ -377,12 +377,71 @@ wire shape.
|
|||
"engine_id": "<id>",
|
||||
"audit_sequence": 7,
|
||||
"policy_version": 2,
|
||||
"policy_digest": "<blake3 hex, OPTIONAL>",
|
||||
"agent": "<did:key handle>",
|
||||
"action": { "kind": "filesystem", "path": "/workspace/x", "mode": "read" },
|
||||
"decision": { "kind": "allow", "rule": "read-workspace" }
|
||||
}
|
||||
```
|
||||
|
||||
### 10.1 Binding the receipt to the rules
|
||||
|
||||
`policy_version` is an **installation counter**, not a content address. A
|
||||
conforming engine SHOULD refuse a non-increasing install, which makes the
|
||||
number identify a ruleset *within one engine instance*; across a restart, a
|
||||
replica, or a different backend it does not, because each starts its own count.
|
||||
`decision.rule` likewise names the rule that decided by **name**. A receipt
|
||||
carrying only those is a pointer into state that only its producer holds: a
|
||||
verifier must be told what engine-A's version 2 contained, by the party whose
|
||||
decision is under audit.
|
||||
|
||||
A receipt therefore **MAY** carry `policy_digest`, a BLAKE3 hex digest of the
|
||||
rules themselves, and an implementation that emits one **MUST** compute it as:
|
||||
|
||||
```
|
||||
BLAKE3( "jouleclaw.policy/1" || rule_count(8, BE) || per rule:
|
||||
len(name) || name
|
||||
"allow" | "deny"
|
||||
"agents:any" | "agents:one_of" || count(8, BE) || (len||id)* [ids SORTED]
|
||||
"action:filesystem" || len(path_glob) || path_glob || count || (len||mode_tag)*
|
||||
| "action:network" || len(host_glob) || host_glob || from(2,BE) || to(2,BE)
|
||||
|| count || (len||method_tag)*
|
||||
| "action:process" || len(binary_glob) || binary_glob )
|
||||
```
|
||||
|
||||
Three rules make it a digest of the *policy* rather than of a serialization:
|
||||
|
||||
- **Rule order is included.** Evaluation scans `Deny` rules first and
|
||||
short-circuits, so reordering can change the outcome: two orderings are two
|
||||
policies.
|
||||
- **The agent list is sorted before hashing.** `agents:one_of` is matched by
|
||||
membership, so two policies differing only in that order are the same policy
|
||||
and **MUST** share a digest.
|
||||
- **A port range is hashed as written, not normalised.** A range whose `from`
|
||||
exceeds its `to` matches nothing, which is a different policy from the same
|
||||
pair the other way round.
|
||||
|
||||
`version` is **excluded** from the digest: the digest answers "which rules", the
|
||||
version travels beside it answering "which installation", and folding the
|
||||
counter in would make one ruleset hash differently per engine.
|
||||
|
||||
Every variable-length part is length-prefixed, so no two distinct policies can
|
||||
share a preimage by moving a field boundary. The mode and method tags are
|
||||
written out above rather than derived from a serializer, so the digest does not
|
||||
move if a `serde` rename or a variant reordering ever lands.
|
||||
|
||||
The digest **MUST** be the one in force when the decision was made, not the
|
||||
engine's current policy — the two differ across an install, and attributing a
|
||||
decision to rules installed after it was made is the error the field exists to
|
||||
prevent. Implementations SHOULD record it on the audit event at decision time.
|
||||
|
||||
When absent, the field **MUST** be omitted from the signed core entirely, not
|
||||
emitted as a null or an empty string: a receipt without the binding then has a
|
||||
signed core byte-identical to one produced before the field existed, which is
|
||||
why the three `policy/1` vectors published before it are unchanged and
|
||||
`policy/1` verifiers keep working. A consumer that requires the binding
|
||||
**MUST** check for the field's presence rather than defaulting it.
|
||||
|
||||
- The canonical signed core is the det-CBOR (RFC 8949 §4.2.1
|
||||
length-first map ordering) of the receipt as JSON.
|
||||
- The COSE_Sign1 envelope is identical in shape to the JCR-1 v1
|
||||
|
|
@ -391,12 +450,14 @@ wire shape.
|
|||
`signature = Ed25519(Sig_structure)`.
|
||||
- Verifiers reject envelopes whose `kid` (in the protected header)
|
||||
disagrees with the embedded `steward`.
|
||||
- Conforming implementations **MUST** byte-match the three reference
|
||||
- Conforming implementations **MUST** byte-match the four reference
|
||||
vectors in
|
||||
[`jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json`](jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json).
|
||||
Reference verifiers in Rust, Python, and Go ship alongside the
|
||||
vectors; all three produce byte-identical canonical bytes for the
|
||||
same receipt.
|
||||
same receipt. Vectors 1-3 omit `policy_digest` and keep the bytes they
|
||||
shipped with; vector 4 is vector 1's decision with the binding, so the two
|
||||
differ by exactly that key.
|
||||
|
||||
Policy receipts and cascade receipts share the same JSONL "unified
|
||||
log" file format: readers dispatch on the `jc_receipt` schema tag
|
||||
|
|
|
|||
1
jouleclaw/jouleclaw-rs/Cargo.lock
generated
1
jouleclaw/jouleclaw-rs/Cargo.lock
generated
|
|
@ -4443,6 +4443,7 @@ dependencies = [
|
|||
name = "jouleclaw-policy"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
|
|
|
|||
|
|
@ -632,6 +632,9 @@ mod tests {
|
|||
engine_id: "engine-A".into(),
|
||||
audit_sequence: 1,
|
||||
policy_version: 1,
|
||||
// The legacy shape: no digest, so the signed core is the one that
|
||||
// shipped before the field existed.
|
||||
policy_digest: None,
|
||||
agent: agent.into(),
|
||||
action,
|
||||
decision,
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,9 @@ mod tests {
|
|||
engine_id: "engine-A".to_string(),
|
||||
audit_sequence: sequence,
|
||||
policy_version: 1,
|
||||
// The legacy shape: no digest, so the signed core is the one that
|
||||
// shipped before the field existed.
|
||||
policy_digest: None,
|
||||
agent: agent.to_string(),
|
||||
action,
|
||||
decision,
|
||||
|
|
|
|||
|
|
@ -402,6 +402,14 @@ pub fn policy_receipt_to_span(p: &PolicyReceipt) -> SpanData {
|
|||
"jouleclaw.policy.policy_version".into(),
|
||||
AttributeValue::Int(p.policy_version as i64),
|
||||
);
|
||||
// Exported beside the version, and only when the receipt carries it. A
|
||||
// span that reported the version alone would name a policy in telemetry
|
||||
// without saying what it was, which is the same gap the receipt field
|
||||
// closes; absent is left absent rather than exported as an empty string,
|
||||
// so a query can tell "no binding" from "a binding that is blank".
|
||||
if let Some(d) = &p.policy_digest {
|
||||
attrs.insert("jouleclaw.policy.policy_digest".into(), AttributeValue::s(d));
|
||||
}
|
||||
attrs.insert(
|
||||
"jouleclaw.steward".into(),
|
||||
AttributeValue::s(&p.steward),
|
||||
|
|
@ -658,6 +666,9 @@ mod tests {
|
|||
engine_id: "engine-test".into(),
|
||||
audit_sequence: 42,
|
||||
policy_version: 1,
|
||||
// The legacy shape: no digest, so the signed core is the one that
|
||||
// shipped before the field existed.
|
||||
policy_digest: None,
|
||||
agent: "did:key:zAgent".into(),
|
||||
action: PolicyActionWire::Filesystem {
|
||||
path: "/workspace/notes.md".into(),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"note": "TEST KEY ONLY. Same seed as JCR-1 conformance/test-keys.json.",
|
||||
"steward_kid": "did:keri:EJ9testStewardAID000000000000000"
|
||||
},
|
||||
"vector_count": 3,
|
||||
"vector_count": 4,
|
||||
"vectors": [
|
||||
{
|
||||
"cose_sign1_hex": "d284582fa201270458296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030a0590123aa6269646c706f6c726370742d30303031656167656e746e6469643a6b65793a7a416c70686166616374696f6ea3646b696e646a66696c6573797374656d646d6f646564726561646470617468732f776f726b73706163652f6e6f7465732e6d64677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e6465616c6c6f776472756c656e726561642d776f726b737061636569636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316e61756469745f73657175656e6365016e706f6c6963795f76657273696f6e015840eceb966d0bc3b90faa8ea33442a846712c66c995450125e1d71b709f24dea47e9fcabb1ad9b7eb110572ecf24b745b15dc20fb1399643167ee4d96d13ea4dc06",
|
||||
|
|
@ -88,6 +88,32 @@
|
|||
"steward": "did:keri:EJ9testStewardAID000000000000000"
|
||||
},
|
||||
"signed_core_cbor_hex": "aa6269646c706f6c726370742d30303033656167656e746d6469643a6b65793a7a4265746166616374696f6ea464686f73746f6170692e6578616d706c652e636f6d646b696e64676e6574776f726b64706f72741901bb666d6574686f6467636f6e6e656374677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e646464656e7966726561736f6e716e6f5f6d61746368696e675f616c6c6f7769636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316e61756469745f73657175656e6365036e706f6c6963795f76657273696f6e01"
|
||||
},
|
||||
{
|
||||
"cose_sign1_hex": "d284582fa201270458296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030a0590173ab6269646c706f6c726370742d30303034656167656e746e6469643a6b65793a7a416c70686166616374696f6ea3646b696e646a66696c6573797374656d646d6f646564726561646470617468732f776f726b73706163652f6e6f7465732e6d64677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e6465616c6c6f776472756c656e726561642d776f726b737061636569636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316d706f6c6963795f6469676573747840616234653464376135393034376235333464306362643238333235306430353961323966353232393761333264373631363962366134383432653866346563396e61756469745f73657175656e6365046e706f6c6963795f76657273696f6e015840f8f76d5c857176584653f1335f7668ee8495d86606ce1dad3c8a9abddfeb25f13306c29d6e9a0de659100346f1023c7d6dd398e0a06875f15574ccd2157f490d",
|
||||
"description": "Policy-bound allow — same decision as vector 1, with the digest of the rules that produced it. Vectors 1-3 omit the field and keep the signed core they shipped with.",
|
||||
"n": 4,
|
||||
"receipt": {
|
||||
"action": {
|
||||
"kind": "filesystem",
|
||||
"mode": "read",
|
||||
"path": "/workspace/notes.md"
|
||||
},
|
||||
"agent": "did:key:zAlpha",
|
||||
"audit_sequence": 4,
|
||||
"closed_at": "2026-06-02T12:00:00Z",
|
||||
"decision": {
|
||||
"kind": "allow",
|
||||
"rule": "read-workspace"
|
||||
},
|
||||
"engine_id": "engine-A",
|
||||
"id": "polrcpt-0004",
|
||||
"jc_receipt": "policy/1",
|
||||
"policy_digest": "ab4e4d7a59047b534d0cbd283250d059a29f52297a32d76169b6a4842e8f4ec9",
|
||||
"policy_version": 1,
|
||||
"steward": "did:keri:EJ9testStewardAID000000000000000"
|
||||
},
|
||||
"signed_core_cbor_hex": "ab6269646c706f6c726370742d30303034656167656e746e6469643a6b65793a7a416c70686166616374696f6ea3646b696e646a66696c6573797374656d646d6f646564726561646470617468732f776f726b73706163652f6e6f7465732e6d64677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e6465616c6c6f776472756c656e726561642d776f726b737061636569636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316d706f6c6963795f6469676573747840616234653464376135393034376235333464306362643238333235306430353961323966353232393761333264373631363962366134383432653866346563396e61756469745f73657175656e6365046e706f6c6963795f76657273696f6e01"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ fn receipt(
|
|||
engine_id: ENGINE_ID.to_string(),
|
||||
audit_sequence: audit_seq,
|
||||
policy_version: 1,
|
||||
// The first three vectors carry no digest, which is what keeps their
|
||||
// signed core byte-identical to the shipped `policy/1` bytes. Vector 4
|
||||
// carries one.
|
||||
policy_digest: None,
|
||||
agent: agent.to_string(),
|
||||
action,
|
||||
decision,
|
||||
|
|
@ -167,12 +171,48 @@ fn main() {
|
|||
&key,
|
||||
);
|
||||
|
||||
// Vector 4 — the policy binding. Same decision as vector 1, with the
|
||||
// digest of the rules that made it. Its signed core therefore differs from
|
||||
// vector 1's by exactly that key, which is the additivity claim made
|
||||
// checkable: vectors 1-3 keep the bytes they shipped with, and a verifier
|
||||
// that encodes the published map needs no change to handle this one.
|
||||
let mut bound = receipt(
|
||||
4,
|
||||
4,
|
||||
PolicyDecisionWire::Allow { rule: "read-workspace".to_string() },
|
||||
PolicyActionWire::Filesystem {
|
||||
path: "/workspace/notes.md".to_string(),
|
||||
mode: "read".to_string(),
|
||||
},
|
||||
"did:key:zAlpha",
|
||||
);
|
||||
// The digest of a real policy, not a literal: a hand-written constant here
|
||||
// would not detect a change to the rendering it is supposed to pin.
|
||||
let policy = jouleclaw_policy::Policy::new(vec![jouleclaw_policy::Rule {
|
||||
name: "read-workspace".to_string(),
|
||||
effect: jouleclaw_policy::Effect::Allow,
|
||||
agents: jouleclaw_policy::AgentScope::one_of(["did:key:zAlpha"]),
|
||||
action: jouleclaw_policy::ActionPredicate::Filesystem {
|
||||
path_glob: "/workspace/**".to_string(),
|
||||
modes: [jouleclaw_policy::FsMode::Read].into_iter().collect(),
|
||||
},
|
||||
}]);
|
||||
bound.policy_digest = Some(policy.digest_hex());
|
||||
let v4 = make_vector(
|
||||
4,
|
||||
"Policy-bound allow — same decision as vector 1, with the digest of the \
|
||||
rules that produced it. Vectors 1-3 omit the field and keep the signed \
|
||||
core they shipped with.",
|
||||
bound,
|
||||
&key,
|
||||
);
|
||||
|
||||
let doc = json!({
|
||||
"spec": "JouleClaw Policy Receipt v1",
|
||||
"schema_id": "policy/1",
|
||||
"canonical_cbor": "RFC 8949 4.2.1 core deterministic (length-first key order)",
|
||||
"envelope": "COSE_Sign1 (RFC 9052), EdDSA/Ed25519",
|
||||
"vector_count": 3,
|
||||
"vector_count": 4,
|
||||
"test_key": {
|
||||
"ed25519_seed_hex": SIGNING_SEED_HEX,
|
||||
"ed25519_public_raw_hex": hex_encode(&public_key),
|
||||
|
|
@ -180,7 +220,7 @@ fn main() {
|
|||
"cose_alg": "EdDSA (-8)",
|
||||
"note": "TEST KEY ONLY. Same seed as JCR-1 conformance/test-keys.json."
|
||||
},
|
||||
"vectors": [v1, v2, v3]
|
||||
"vectors": [v1, v2, v3, v4]
|
||||
});
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&doc).expect("pretty json");
|
||||
|
|
|
|||
|
|
@ -142,7 +142,35 @@ pub struct PolicyReceipt {
|
|||
/// Monotonic sequence within the source engine's audit log.
|
||||
pub audit_sequence: u64,
|
||||
/// Policy version under which the decision was made.
|
||||
///
|
||||
/// A per-engine installation counter. Within one engine instance it
|
||||
/// identifies a ruleset; across instances or backends it does not — see
|
||||
/// `policy_digest`.
|
||||
pub policy_version: u64,
|
||||
/// `Policy::digest_hex` of the rules that produced this decision.
|
||||
///
|
||||
/// `policy_version` is an installation counter, unique to a ruleset only
|
||||
/// within one engine instance (the bundled engine refuses a non-increasing
|
||||
/// install; the trait does not require one). Across a restart, a replica,
|
||||
/// or another backend, the same number names different rules — so a receipt
|
||||
/// citing the version and `engine_id` alone is a pointer into state only
|
||||
/// its producer holds. This field is what binds the receipt to the rules
|
||||
/// themselves.
|
||||
///
|
||||
/// It is the digest recorded on the `AuditEvent` **at decision time**, not
|
||||
/// the engine's current policy: the two differ across an `install_policy`,
|
||||
/// and attributing a decision to rules installed after it was made is the
|
||||
/// error this field exists to prevent.
|
||||
///
|
||||
/// `None` is the older shape, for an engine that cannot report a digest.
|
||||
/// It is skipped on the wire *and* omitted from [`canonical_wire`], so a
|
||||
/// receipt without it has a signed core byte-identical to one produced
|
||||
/// before the field existed — the published `policy/1` vectors are
|
||||
/// unchanged, and `policy/1` verifiers keep working. A consumer that
|
||||
/// requires the binding must check for the field, which is why its absence
|
||||
/// is representable rather than defaulted to something.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub policy_digest: Option<String>,
|
||||
/// The agent the action was attributed to.
|
||||
pub agent: String,
|
||||
pub action: PolicyActionWire,
|
||||
|
|
@ -268,6 +296,9 @@ where
|
|||
engine_id: self.engine_id.clone(),
|
||||
audit_sequence: event.sequence,
|
||||
policy_version: event.policy_version,
|
||||
// Copied from the event, so the receipt names the rules that
|
||||
// decided rather than whatever is installed now.
|
||||
policy_digest: event.policy_digest.clone(),
|
||||
agent: event.agent.as_str().to_string(),
|
||||
action: PolicyActionWire::from(&event.action),
|
||||
decision: PolicyDecisionWire::from(&event.decision),
|
||||
|
|
@ -313,6 +344,12 @@ pub fn canonical_wire(receipt: &PolicyReceipt) -> Value {
|
|||
obj.insert("engine_id".into(), json!(receipt.engine_id));
|
||||
obj.insert("audit_sequence".into(), json!(receipt.audit_sequence));
|
||||
obj.insert("policy_version".into(), json!(receipt.policy_version));
|
||||
// Inserted only when present. The JCR-1 canonicaliser refuses nulls, and a
|
||||
// key carrying an empty string would change the core bytes for every
|
||||
// pre-existing receipt — so absence must be absence, not a placeholder.
|
||||
if let Some(d) = &receipt.policy_digest {
|
||||
obj.insert("policy_digest".into(), json!(d));
|
||||
}
|
||||
obj.insert("agent".into(), json!(receipt.agent));
|
||||
obj.insert("action".into(), action_to_value(&receipt.action));
|
||||
obj.insert("decision".into(), decision_to_value(&receipt.decision));
|
||||
|
|
@ -1024,7 +1061,7 @@ mod tests {
|
|||
let seed = hex_decode(seed_hex);
|
||||
let key = SigningKey::from_bytes(seed.as_slice().try_into().unwrap());
|
||||
let vectors = doc["vectors"].as_array().expect("vectors array");
|
||||
assert_eq!(vectors.len(), 3, "this version pins 3 vectors");
|
||||
assert_eq!(vectors.len(), 4, "this version pins 4 vectors");
|
||||
for v in vectors {
|
||||
let n = v["n"].as_u64().expect("n");
|
||||
let receipt: PolicyReceipt = serde_json::from_value(v["receipt"].clone())
|
||||
|
|
@ -1139,4 +1176,168 @@ mod tests {
|
|||
let r: PolicyReceipt = serde_json::from_str(legacy).expect("legacy parse");
|
||||
assert!(r.context.is_none());
|
||||
}
|
||||
// ── The policy binding ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn two_engines_at_one_version_hold_different_rules_and_only_the_digest_says_so() {
|
||||
// The hole, stated accurately. `InMemoryPolicyEngine::install_policy`
|
||||
// does enforce strictly-increasing versions, so *within one instance* a
|
||||
// version identifies a ruleset. What it cannot do is make the number
|
||||
// mean anything outside that instance: a restart, a replica, or a
|
||||
// second host starts at version 1 with whatever rules it was given.
|
||||
//
|
||||
// `engine_id` distinguishes the producers, but nothing binds an
|
||||
// (engine_id, version) pair to a ruleset — a verifier has to be *told*
|
||||
// what engine-A's version 1 contained, by the same party that produced
|
||||
// the receipt. The digest is what makes the receipt self-describing.
|
||||
let narrow = InMemoryPolicyEngine::new(Policy::new(vec![Rule::allow(
|
||||
"read-ws",
|
||||
AgentScope::Any,
|
||||
)
|
||||
.filesystem("/workspace/**", [FsMode::Read])]));
|
||||
let wide = InMemoryPolicyEngine::new(Policy::new(vec![Rule::allow(
|
||||
"read-ws",
|
||||
AgentScope::Any,
|
||||
)
|
||||
.filesystem("/**", [FsMode::Read])]));
|
||||
|
||||
assert_eq!(narrow.policy_version(), wide.policy_version(), "both are version 1");
|
||||
let act = Action::filesystem("/workspace/notes.md", FsMode::Read);
|
||||
let a = build_one(act.clone(), &narrow);
|
||||
let b = build_one(act, &wide);
|
||||
|
||||
// Everything a `policy/1` receipt said about the policy is identical,
|
||||
// and the decisions match too — yet one engine would also have allowed
|
||||
// /etc/shadow and the other would not.
|
||||
assert_eq!(a.policy_version, b.policy_version);
|
||||
assert_eq!(a.decision, b.decision);
|
||||
assert!(wide
|
||||
.evaluate(&AgentId::new("agent-007"), &Action::filesystem("/etc/shadow", FsMode::Read))
|
||||
.is_allow());
|
||||
assert!(!narrow
|
||||
.evaluate(&AgentId::new("agent-007"), &Action::filesystem("/etc/shadow", FsMode::Read))
|
||||
.is_allow());
|
||||
|
||||
// Only the digest separates them.
|
||||
assert_ne!(a.policy_digest, b.policy_digest);
|
||||
assert!(a.policy_digest.is_some() && b.policy_digest.is_some());
|
||||
|
||||
// And it is in the signed core, so it is a binding rather than an
|
||||
// annotation a producer can drop on the way out.
|
||||
assert_ne!(
|
||||
canonical_signed_core(&a).expect("core"),
|
||||
canonical_signed_core(&b).expect("core")
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&canonical_signed_core(&a).unwrap())
|
||||
.contains("policy_digest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_receipt_names_the_rules_in_force_when_the_decision_was_made() {
|
||||
// Within one engine the version does move, and the digest must move
|
||||
// with it — on the event, not read off the engine afterwards.
|
||||
let engine = InMemoryPolicyEngine::new(Policy::new(vec![Rule::allow(
|
||||
"read-ws",
|
||||
AgentScope::Any,
|
||||
)
|
||||
.filesystem("/workspace/**", [FsMode::Read])]));
|
||||
let before = engine.policy_digest().expect("the bundled engine reports one");
|
||||
let first = build_one(Action::filesystem("/workspace/a.md", FsMode::Read), &engine);
|
||||
assert_eq!(first.policy_digest.as_deref(), Some(before.as_str()));
|
||||
|
||||
engine
|
||||
.install_policy(Policy::new_at_version(
|
||||
vec![Rule::allow("read-all", AgentScope::Any).filesystem("/**", [FsMode::Read])],
|
||||
2,
|
||||
))
|
||||
.expect("install");
|
||||
let after = engine.policy_digest().expect("still reports one");
|
||||
assert_ne!(before, after);
|
||||
|
||||
// The receipt built from the earlier event still names the earlier
|
||||
// rules — the point of recording the digest at decision time.
|
||||
let mut builder = PolicyReceiptBuilder {
|
||||
steward: "did:key:test".to_string(),
|
||||
engine_id: "engine-A".to_string(),
|
||||
now: deterministic_now,
|
||||
id_gen: deterministic_ids(),
|
||||
};
|
||||
let replayed = builder.build(&engine.audit()[0]);
|
||||
assert_eq!(replayed.policy_digest.as_deref(), Some(before.as_str()));
|
||||
assert_eq!(replayed.policy_version, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_receipt_without_a_digest_has_the_core_it_always_had() {
|
||||
// Additivity, checked against the published bytes rather than asserted:
|
||||
// vector 1 omits the field, and its signed core must still be the hex
|
||||
// that shipped. `policy_vectors_match_committed_conformance` covers all
|
||||
// four; this names why vector 1 in particular matters.
|
||||
let doc: serde_json::Value = serde_json::from_str(POLICY_VECTORS_JSON).unwrap();
|
||||
let v1 = &doc["vectors"][0];
|
||||
let r1: PolicyReceipt = serde_json::from_value(v1["receipt"].clone()).unwrap();
|
||||
assert_eq!(r1.policy_digest, None, "vector 1 is the digest-less shape");
|
||||
let core = canonical_signed_core(&r1).expect("core");
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&core).contains("policy_digest"),
|
||||
"an absent digest must not appear in the signed bytes at all"
|
||||
);
|
||||
assert_eq!(hex_encode(&core), v1["signed_core_cbor_hex"].as_str().unwrap());
|
||||
|
||||
// Vector 4 is the same decision with the binding, so the two differ by
|
||||
// exactly that key — which is what makes the additivity claim
|
||||
// non-vacuous rather than a statement about an empty set.
|
||||
let v4 = &doc["vectors"][3];
|
||||
let r4: PolicyReceipt = serde_json::from_value(v4["receipt"].clone()).unwrap();
|
||||
assert!(r4.policy_digest.is_some());
|
||||
assert_ne!(
|
||||
v4["signed_core_cbor_hex"].as_str(),
|
||||
v1["signed_core_cbor_hex"].as_str()
|
||||
);
|
||||
let (w1, w4) = (canonical_wire(&r1), canonical_wire(&r4));
|
||||
let only_in_4: Vec<&String> = w4
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|k| !w1.as_object().unwrap().contains_key(*k))
|
||||
.collect();
|
||||
assert_eq!(only_in_4, vec!["policy_digest"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shipped_receipt_carries_the_digest_of_the_rules_in_force_at_the_decision() {
|
||||
// `PolicyShipper` reads events after the fact. If the receipt took the
|
||||
// digest from the engine at shipping time it would attribute a decision
|
||||
// to rules installed after it was made.
|
||||
let first = Policy::new(vec![Rule::allow("read-ws", AgentScope::Any)
|
||||
.filesystem("/workspace/**", [FsMode::Read])]);
|
||||
let engine = InMemoryPolicyEngine::new(first);
|
||||
let d1 = engine.policy_digest().unwrap();
|
||||
let agent = AgentId::new("did:key:zAlpha");
|
||||
engine.evaluate(&agent, &Action::filesystem("/workspace/a.md", FsMode::Read));
|
||||
|
||||
engine
|
||||
.install_policy(Policy::new_at_version(
|
||||
vec![Rule::allow("read-all", AgentScope::Any)
|
||||
.filesystem("/**", [FsMode::Read])],
|
||||
2,
|
||||
))
|
||||
.expect("install");
|
||||
let d2 = engine.policy_digest().unwrap();
|
||||
engine.evaluate(&agent, &Action::filesystem("/etc/hosts", FsMode::Read));
|
||||
assert_ne!(d1, d2);
|
||||
|
||||
let mut shipper = PolicyShipper::new(
|
||||
"did:key:test",
|
||||
"engine-A",
|
||||
deterministic_now,
|
||||
deterministic_ids(),
|
||||
);
|
||||
let mut sink = InMemoryPolicyReceiptSink::new();
|
||||
shipper.ship(&engine, &mut sink).expect("ship");
|
||||
let shipped: Vec<&PolicyReceipt> = sink.iter().collect();
|
||||
assert_eq!(shipped.len(), 2);
|
||||
assert_eq!(shipped[0].policy_digest.as_deref(), Some(d1.as_str()));
|
||||
assert_eq!(shipped[1].policy_digest.as_deref(), Some(d2.as_str()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ description = "JouleClaw out-of-process deny-by-default policy engine — filesy
|
|||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -410,6 +410,119 @@ impl Policy {
|
|||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Content address of this policy — BLAKE3 over the rules themselves,
|
||||
/// hex-encoded.
|
||||
///
|
||||
/// `version` is a per-engine counter, not a content address.
|
||||
/// [`InMemoryPolicyEngine::install_policy`] does enforce strictly
|
||||
/// increasing versions, so *within one instance* a version identifies a
|
||||
/// ruleset. What the counter cannot do is mean anything outside that
|
||||
/// instance: a restart, a replica, or a second host starts at version 1
|
||||
/// with whatever rules it was given, and the monotonic guard lives in the
|
||||
/// bundled engine rather than in the [`PolicyEngine`] contract, so another
|
||||
/// backend need not have one at all. Either way a record citing the
|
||||
/// version is a pointer into state only its producer holds — a verifier
|
||||
/// has to be *told* what engine-A's version 1 contained, by the party whose
|
||||
/// decision is under audit. This is what makes the record self-describing.
|
||||
///
|
||||
/// **Order is in the digest**, because order is semantics: [`decide`]
|
||||
/// scans `Deny` rules first and short-circuits, so reordering rules can
|
||||
/// change the outcome. Within a rule, the agent list is *sorted* before
|
||||
/// hashing — `AgentScope::OneOf` is matched by membership, so two policies
|
||||
/// differing only in that order are the same policy and must have the same
|
||||
/// digest.
|
||||
///
|
||||
/// `version` is deliberately **excluded**. A digest answers "which rules",
|
||||
/// and the version travels beside it answering "which installation"; folding
|
||||
/// the counter in would make the same ruleset hash differently per engine
|
||||
/// and defeat the comparison the digest exists for.
|
||||
///
|
||||
/// The byte rendering is written out rather than delegated to a serializer,
|
||||
/// so a second implementation can reproduce it from this source. Every
|
||||
/// variable-length part is preceded by its length, so no two distinct
|
||||
/// policies can produce the same preimage by moving where one field ends.
|
||||
pub fn digest_hex(&self) -> String {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(b"jouleclaw.policy/1");
|
||||
h.update(&(self.rules.len() as u64).to_be_bytes());
|
||||
for r in &self.rules {
|
||||
field(&mut h, r.name.as_bytes());
|
||||
h.update(match r.effect {
|
||||
Effect::Allow => b"allow",
|
||||
Effect::Deny => b"deny",
|
||||
});
|
||||
match &r.agents {
|
||||
AgentScope::Any => {
|
||||
h.update(b"agents:any");
|
||||
}
|
||||
AgentScope::OneOf { ids } => {
|
||||
h.update(b"agents:one_of");
|
||||
let mut sorted: Vec<&str> = ids.iter().map(|a| a.0.as_str()).collect();
|
||||
sorted.sort_unstable();
|
||||
h.update(&(sorted.len() as u64).to_be_bytes());
|
||||
for id in sorted {
|
||||
field(&mut h, id.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
match &r.action {
|
||||
ActionPredicate::Filesystem { path_glob, modes } => {
|
||||
h.update(b"action:filesystem");
|
||||
field(&mut h, path_glob.as_bytes());
|
||||
h.update(&(modes.len() as u64).to_be_bytes());
|
||||
for m in modes {
|
||||
field(&mut h, fs_mode_tag(*m).as_bytes());
|
||||
}
|
||||
}
|
||||
ActionPredicate::Network { host_glob, ports, methods } => {
|
||||
h.update(b"action:network");
|
||||
field(&mut h, host_glob.as_bytes());
|
||||
// Rendered as given, not normalised: a range whose `from`
|
||||
// exceeds its `to` matches nothing, which is a different
|
||||
// policy from the same pair the other way round.
|
||||
h.update(&ports.from.to_be_bytes());
|
||||
h.update(&ports.to.to_be_bytes());
|
||||
h.update(&(methods.len() as u64).to_be_bytes());
|
||||
for m in methods {
|
||||
field(&mut h, net_method_tag(*m).as_bytes());
|
||||
}
|
||||
}
|
||||
ActionPredicate::Process { binary_glob } => {
|
||||
h.update(b"action:process");
|
||||
field(&mut h, binary_glob.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
h.finalize().to_hex().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Length-prefix one variable-length field so no two distinct policies share a
|
||||
/// preimage by shifting a boundary.
|
||||
fn field(h: &mut blake3::Hasher, bytes: &[u8]) {
|
||||
h.update(&(bytes.len() as u64).to_be_bytes());
|
||||
h.update(bytes);
|
||||
}
|
||||
|
||||
/// Wire tag for a filesystem mode. Written out so the digest does not move if
|
||||
/// a serde rename or variant order ever changes.
|
||||
fn fs_mode_tag(m: FsMode) -> &'static str {
|
||||
match m {
|
||||
FsMode::Read => "read",
|
||||
FsMode::Write => "write",
|
||||
FsMode::Execute => "execute",
|
||||
FsMode::List => "list",
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire tag for a network method, for the same reason as [`fs_mode_tag`].
|
||||
fn net_method_tag(m: NetMethod) -> &'static str {
|
||||
match m {
|
||||
NetMethod::Connect => "connect",
|
||||
NetMethod::Listen => "listen",
|
||||
NetMethod::Resolve => "resolve",
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -451,6 +564,19 @@ pub struct AuditEvent {
|
|||
pub sequence: u64,
|
||||
/// Policy version under which the evaluation ran.
|
||||
pub policy_version: u64,
|
||||
/// [`Policy::digest_hex`] of the policy that actually decided.
|
||||
///
|
||||
/// Recorded here, at decision time, rather than read off the engine when
|
||||
/// the event is later shipped: the live policy can be replaced in between,
|
||||
/// and a record that cited the *then-current* digest would attribute the
|
||||
/// decision to rules that had not been installed when it was made.
|
||||
///
|
||||
/// `None` on events from an engine that does not report one. That is the
|
||||
/// older, weaker shape and is skipped on the wire, so an `AuditEvent`
|
||||
/// serialized before this field existed still deserializes and still
|
||||
/// round-trips byte-identically.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub policy_digest: Option<String>,
|
||||
pub agent: AgentId,
|
||||
pub action: Action,
|
||||
pub decision: Decision,
|
||||
|
|
@ -536,6 +662,21 @@ pub trait PolicyEngine: Send + Sync {
|
|||
fn audit_since(&self, since: u64) -> Vec<AuditEvent>;
|
||||
/// Currently-installed policy version.
|
||||
fn policy_version(&self) -> u64;
|
||||
/// [`Policy::digest_hex`] of the currently-installed policy, where the
|
||||
/// backend can produce one.
|
||||
///
|
||||
/// Defaults to `None` so an engine that holds its rules somewhere it cannot
|
||||
/// hash — a remote service, a kernel module — stays conformant rather than
|
||||
/// being forced to invent a digest. A consumer that requires the binding
|
||||
/// should refuse such an engine explicitly, which it can only do if the
|
||||
/// absence is reported rather than papered over.
|
||||
///
|
||||
/// This is the *current* policy. For the policy a given decision was made
|
||||
/// under, read [`AuditEvent::policy_digest`]; the two differ across an
|
||||
/// [`PolicyEngine::install_policy`].
|
||||
fn policy_digest(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Replace the live policy. The default implementation returns
|
||||
/// [`PolicyError::EnginePoisoned`] as a stand-in for "this backend
|
||||
/// is read-only / does not support live policy updates" — the
|
||||
|
|
@ -615,6 +756,7 @@ impl PolicyEngine for InMemoryPolicyEngine {
|
|||
let event = AuditEvent {
|
||||
sequence: g.next_seq,
|
||||
policy_version: g.policy.version,
|
||||
policy_digest: Some(g.policy.digest_hex()),
|
||||
agent: agent.clone(),
|
||||
action: action.clone(),
|
||||
decision: decision.clone(),
|
||||
|
|
@ -652,6 +794,14 @@ impl PolicyEngine for InMemoryPolicyEngine {
|
|||
g.policy.version
|
||||
}
|
||||
|
||||
fn policy_digest(&self) -> Option<String> {
|
||||
let g = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
Some(g.policy.digest_hex())
|
||||
}
|
||||
|
||||
fn install_policy(&self, new_policy: Policy) -> Result<u64, PolicyError> {
|
||||
// Delegate to the inherent method so the wire path and the
|
||||
// in-process call share one implementation.
|
||||
|
|
@ -667,6 +817,7 @@ impl PolicyEngine for InMemoryPolicyEngine {
|
|||
let event = AuditEvent {
|
||||
sequence: g.next_seq,
|
||||
policy_version: g.policy.version,
|
||||
policy_digest: Some(g.policy.digest_hex()),
|
||||
agent: agent.clone(),
|
||||
action: action.clone(),
|
||||
decision: traced.decision.clone(),
|
||||
|
|
@ -1360,4 +1511,228 @@ mod tests {
|
|||
let back: TracedDecision = serde_json::from_str(&json).expect("from_str");
|
||||
assert_eq!(back, traced);
|
||||
}
|
||||
// ─── the policy digest ───────────────────────────────────────────
|
||||
|
||||
fn fs_rule(name: &str, glob: &str) -> Rule {
|
||||
Rule {
|
||||
name: name.to_string(),
|
||||
effect: Effect::Allow,
|
||||
agents: AgentScope::Any,
|
||||
action: ActionPredicate::Filesystem {
|
||||
path_glob: glob.to_string(),
|
||||
modes: [FsMode::Read].into_iter().collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapping_the_rules_under_one_version_changes_the_digest() {
|
||||
// The hole. `policy_version` is an installation counter, so a record
|
||||
// citing it alone keeps verifying after the ruleset is replaced — while
|
||||
// describing a decision the new rules would not make.
|
||||
let open = Policy::new_at_version(vec![fs_rule("read-ws", "/workspace/**")], 3);
|
||||
let wide = Policy::new_at_version(vec![fs_rule("read-ws", "/**")], 3);
|
||||
assert_eq!(open.version, wide.version, "the counter cannot tell these apart");
|
||||
assert_ne!(open.digest_hex(), wide.digest_hex(), "the digest must");
|
||||
// And the same rules at a different version share a digest: the digest
|
||||
// answers "which rules", the version answers "which installation".
|
||||
assert_eq!(
|
||||
open.digest_hex(),
|
||||
Policy::new_at_version(vec![fs_rule("read-ws", "/workspace/**")], 99).digest_hex()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_order_is_in_the_digest_because_order_is_semantics() {
|
||||
// `decide` scans Deny rules first and short-circuits, so reordering can
|
||||
// change the outcome — two orderings are two policies.
|
||||
let deny = Rule {
|
||||
name: "no-secrets".to_string(),
|
||||
effect: Effect::Deny,
|
||||
agents: AgentScope::Any,
|
||||
action: ActionPredicate::Filesystem {
|
||||
path_glob: "/workspace/.secrets/**".to_string(),
|
||||
modes: [FsMode::Read].into_iter().collect(),
|
||||
},
|
||||
};
|
||||
let allow = fs_rule("read-ws", "/workspace/**");
|
||||
let a = Policy::new(vec![deny.clone(), allow.clone()]);
|
||||
let b = Policy::new(vec![allow, deny]);
|
||||
assert_ne!(a.digest_hex(), b.digest_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_list_order_is_not_in_the_digest_because_it_is_a_set() {
|
||||
// `AgentScope::OneOf` is matched by membership. Two policies differing
|
||||
// only in that order are the same policy, and must not look different.
|
||||
let mk = |ids: [&str; 3]| {
|
||||
Policy::new(vec![Rule {
|
||||
name: "r".to_string(),
|
||||
effect: Effect::Allow,
|
||||
agents: AgentScope::one_of(ids),
|
||||
action: ActionPredicate::Process { binary_glob: "/bin/ls".to_string() },
|
||||
}])
|
||||
};
|
||||
assert_eq!(
|
||||
mk(["a", "b", "c"]).digest_hex(),
|
||||
mk(["c", "a", "b"]).digest_hex()
|
||||
);
|
||||
// A different membership is a different policy.
|
||||
assert_ne!(mk(["a", "b", "c"]).digest_hex(), mk(["a", "b", "d"]).digest_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_two_policies_share_a_preimage_by_shifting_a_boundary() {
|
||||
// Where the length prefixes are actually load-bearing: two *arbitrary*
|
||||
// strings that sit next to each other in the preimage. The agent id
|
||||
// list is the only such place — elsewhere a constant tag ("allow",
|
||||
// "action:filesystem", ...) separates the variable parts, so a rule
|
||||
// named "ab" with glob "c" is already distinguishable from one named
|
||||
// "a" with glob "bc" without any prefix at all.
|
||||
//
|
||||
// Here nothing separates one id from the next, and the count is the
|
||||
// same either way, so without the prefixes these two render to the
|
||||
// identical byte run "abc".
|
||||
let ids = |a: &str, b: &str| {
|
||||
Policy::new(vec![Rule {
|
||||
name: "r".to_string(),
|
||||
effect: Effect::Allow,
|
||||
agents: AgentScope::one_of([a, b]),
|
||||
action: ActionPredicate::Process { binary_glob: "/bin/ls".to_string() },
|
||||
}])
|
||||
};
|
||||
assert_ne!(ids("ab", "c").digest_hex(), ids("a", "bc").digest_hex());
|
||||
// Sorting puts both pairs in the same order first, so this is a
|
||||
// boundary collision and not an ordering difference.
|
||||
assert_ne!(ids("c", "ab").digest_hex(), ids("bc", "a").digest_hex());
|
||||
|
||||
// One rule is not two, and the rule count is what says so.
|
||||
let one = Policy::new(vec![fs_rule("ab", "/x")]);
|
||||
let two = Policy::new(vec![fs_rule("a", "/x"), fs_rule("b", "/x")]);
|
||||
assert_ne!(one.digest_hex(), two.digest_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_port_range_is_digested_as_written_not_normalised() {
|
||||
// `from` > `to` matches nothing; the same pair reversed matches five
|
||||
// ports. Normalising before hashing would merge an always-deny rule
|
||||
// with a permissive one.
|
||||
let mk = |from: u16, to: u16| {
|
||||
Policy::new(vec![Rule {
|
||||
name: "r".to_string(),
|
||||
effect: Effect::Allow,
|
||||
agents: AgentScope::Any,
|
||||
action: ActionPredicate::Network {
|
||||
host_glob: "*".to_string(),
|
||||
ports: PortRange { from, to },
|
||||
methods: [NetMethod::Connect].into_iter().collect(),
|
||||
},
|
||||
}])
|
||||
};
|
||||
assert!(!PortRange { from: 5, to: 1 }.contains(3), "premise: this matches nothing");
|
||||
assert!(PortRange { from: 1, to: 5 }.contains(3));
|
||||
assert_ne!(mk(5, 1).digest_hex(), mk(1, 5).digest_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_policy_still_has_a_digest_distinct_from_any_rule() {
|
||||
let empty = Policy::empty();
|
||||
assert_eq!(empty.digest_hex().len(), 64);
|
||||
assert_ne!(empty.digest_hex(), Policy::new(vec![fs_rule("r", "/x")]).digest_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_audit_event_records_the_policy_that_decided_not_the_one_installed_later() {
|
||||
// The reason the digest is captured at decision time. Reading it off
|
||||
// the engine at shipping time would attribute a decision to rules that
|
||||
// were not installed when it was made.
|
||||
let engine = InMemoryPolicyEngine::new(Policy::new(vec![fs_rule("read-ws", "/workspace/**")]));
|
||||
let first = engine.policy_digest().expect("the bundled engine reports one");
|
||||
let agent = AgentId::new("did:key:zAlpha");
|
||||
engine.evaluate(
|
||||
&agent,
|
||||
&Action::Filesystem(FsAction { path: "/workspace/notes.md".into(), mode: FsMode::Read }),
|
||||
);
|
||||
|
||||
engine
|
||||
.install_policy(Policy::new_at_version(vec![fs_rule("read-ws", "/**")], 2))
|
||||
.expect("install");
|
||||
let second = engine.policy_digest().expect("still reports one");
|
||||
assert_ne!(first, second, "the ruleset really changed");
|
||||
|
||||
let events = engine.audit();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0].policy_digest.as_deref(),
|
||||
Some(first.as_str()),
|
||||
"the event must still name the rules that decided it"
|
||||
);
|
||||
// A decision after the install names the new rules.
|
||||
engine.evaluate(
|
||||
&agent,
|
||||
&Action::Filesystem(FsAction { path: "/etc/hosts".into(), mode: FsMode::Read }),
|
||||
);
|
||||
assert_eq!(engine.audit()[1].policy_digest.as_deref(), Some(second.as_str()));
|
||||
// The traced path records it too — it is a second construction site,
|
||||
// and a digest on one but not the other is a gap that looks like data.
|
||||
engine.evaluate_traced(
|
||||
&agent,
|
||||
&Action::Filesystem(FsAction { path: "/etc/hosts".into(), mode: FsMode::Read }),
|
||||
);
|
||||
assert_eq!(engine.audit()[2].policy_digest.as_deref(), Some(second.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_engine_that_cannot_hash_its_rules_reports_absence_rather_than_inventing_one() {
|
||||
struct Remote;
|
||||
impl PolicyEngine for Remote {
|
||||
fn evaluate(&self, _a: &AgentId, _x: &Action) -> Decision {
|
||||
Decision::Deny { reason: DenyReason::NoMatchingAllow }
|
||||
}
|
||||
fn audit(&self) -> Vec<AuditEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
fn audit_since(&self, _since: u64) -> Vec<AuditEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
fn policy_version(&self) -> u64 {
|
||||
7
|
||||
}
|
||||
}
|
||||
assert_eq!(Remote.policy_digest(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_audit_event_without_a_digest_round_trips_as_it_always_did() {
|
||||
// The field is additive: an event serialized before it existed still
|
||||
// deserializes, and one with no digest serializes without the key.
|
||||
// Built by serializing a real event and deleting the key, rather than
|
||||
// hand-written: a hand-written literal that drifts from the real shape
|
||||
// fails for the wrong reason and proves nothing about the field.
|
||||
let current = AuditEvent {
|
||||
sequence: 1,
|
||||
policy_version: 1,
|
||||
policy_digest: Some("deadbeef".to_string()),
|
||||
agent: AgentId::new("did:key:zAlpha"),
|
||||
action: Action::Filesystem(FsAction { path: "/x".into(), mode: FsMode::Read }),
|
||||
decision: Decision::Deny { reason: DenyReason::NoMatchingAllow },
|
||||
};
|
||||
let mut v: serde_json::Value = serde_json::to_value(¤t).expect("to_value");
|
||||
assert!(v.get("policy_digest").is_some(), "premise: the field is on the wire");
|
||||
v.as_object_mut().unwrap().remove("policy_digest");
|
||||
let legacy = serde_json::to_string(&v).expect("to_string");
|
||||
|
||||
let e: AuditEvent = serde_json::from_str(&legacy).expect("legacy event still parses");
|
||||
assert_eq!(e.policy_digest, None);
|
||||
let back = serde_json::to_string(&e).expect("to_string");
|
||||
assert!(!back.contains("policy_digest"), "absence must not become a null");
|
||||
// Compared as values, not strings: `serde_json::Value` sorts keys and
|
||||
// the struct does not, so a string comparison would fail on ordering
|
||||
// and say nothing about the field.
|
||||
assert_eq!(
|
||||
serde_json::to_value(&e).expect("to_value"), v,
|
||||
"a digest-less event round-trips to exactly the legacy wire"
|
||||
);
|
||||
let _ = back;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue