vta-service 0.18.0

Service for Verifiable Trust Agents operating in Verifiable Trust Communities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! Runtime Policy Decision Point management — the operations behind the
//! canonical `policy/{list,get,upsert,delete}` Trust Tasks.
//!
//! Before this, the VTA had no runtime policy surface: policy rows were written
//! only by the boot installer, and the sole operator control was editing
//! `config.toml` and restarting. That is why the declarative approvals model
//! now lives in a policy row — it is the first thing that needed to be editable
//! at runtime over whatever transport the VTA actually advertises.
//!
//! # Why writing policy is super-admin, and gateable
//!
//! Whoever can write policy can delete the rule that gates them, so `upsert` and
//! `delete` are super-admin. They are deliberately **not** exempt from the PDP
//! gate: an operator who wants two-person control over changes to the gate
//! itself gets it by writing a `consent` rule for `policy/upsert/0.2`, and that
//! is a feature worth having.
//!
//! The lockout that arrangement risks — approvers whose keys are gone — is
//! answered by the offline break-glass (`vta approvals …`, direct keyspace
//! access with the daemon stopped), not by making the surface ungateable. The
//! same trade the mnemonic-export guard and the Mode-B carve-out make: keep the
//! online path strict, keep one physical-possession escape hatch.

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;

/// Default page size for `policy/list` when the caller names none.
const DEFAULT_PAGE_SIZE: usize = 50;
/// Ceiling on `pageSize`, so one call can't be asked to serialize the world.
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,
    }
}

/// `policy/list/0.2`. Auth: admin (reading policy is not a secret-bearing act,
/// but it does disclose the shape of the VTA's defences).
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?;
    // Deterministic order: priority desc (the order they actually evaluate in),
    // then id, so paging is stable across calls.
    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 {
            // An unscoped policy applies everywhere, so it matches every
            // context filter — filtering it out would misreport what governs
            // that context.
            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 paging is not implemented: the policy set is operator-authored
        // and small (single digits). `truncated` tells a caller to raise
        // `pageSize` rather than promising a cursor that does nothing.
        cursor: None,
    })
}

/// `policy/get/0.1`. Auth: admin.
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) })
}

/// `policy/upsert/0.2`. Auth: super-admin.
///
/// Order of checks is deliberate: authorize, then validate the *content*, then
/// take the optimistic-concurrency decision, then write. A caller with a stale
/// `expectedVersion` and a broken module should hear about the broken module —
/// it is the thing they can fix without re-reading state.
pub async fn upsert_policy(
    policy_ks: &KeyspaceHandle,
    audit_ks: &KeyspaceHandle,
    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());

    // A module that does not compile is skipped at load time with an error log,
    // which silently un-gates every task it named. Refuse it at the door.
    vta_policy::compile(&req.module, &id)
        .map_err(|e| AppError::Validation(format!("policy `{id}` does not compile: {e}")))?;

    // The reserved declarative row and the declarative `ext` marker must imply
    // each other. Without both guards, an operator could either overwrite the
    // approvals row with unrelated Rego (leaving `pnm approvals list` reporting
    // rules that no longer decide anything) or stand up a second row claiming
    // to be the model.
    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_ks,
        "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,
    })
}

/// `policy/delete/0.1`. Auth: super-admin.
pub async fn delete_policy(
    policy_ks: &KeyspaceHandle,
    audit_ks: &KeyspaceHandle,
    auth: &AuthClaims,
    id: &str,
    expected_version: Option<u64>,
    reason: Option<&str>,
    channel: &str,
) -> Result<DeletePolicyResultBody, AppError> {
    auth.require_super_admin()?;

    // The boot-installed baseline is what every task the operator's own
    // policies do not name falls through to. Deleting it turns the PDP's
    // default-deny into the answer for all of them the moment enforcement is
    // on — and it is only reinstalled when the keyspace is *empty*, so the
    // mistake does not heal on restart.
    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_ks,
        "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, KeyspaceHandle, 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(),
            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,
            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_ks, _d) = keyspaces().await;
        let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
        let out = upsert_policy(
            &policy_ks,
            &audit_ks,
            &super_admin(),
            declarative_body(&rules, None),
            "test",
        )
        .await
        .expect("matching row accepted");
        assert!(out.created);
        assert_eq!(out.policy.version, 1);
    }

    /// The check the whole declarative design rests on: rules that say one
    /// thing and Rego that does another must not be storable, or everything
    /// `pnm approvals list` prints is advisory rather than true.
    #[tokio::test]
    async fn a_declarative_row_whose_module_contradicts_its_rules_is_refused() {
        let (policy_ks, audit_ks, _d) = keyspaces().await;
        let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
        let err = upsert_policy(
            &policy_ks,
            &audit_ks,
            &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:?}"
        );
    }

    /// Overwriting the reserved row with unrelated Rego would leave the
    /// approvals surface reporting rules that no longer decide anything.
    #[tokio::test]
    async fn the_reserved_id_cannot_hold_hand_authored_rego() {
        let (policy_ks, audit_ks, _d) = keyspaces().await;
        let err = upsert_policy(
            &policy_ks,
            &audit_ks,
            &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:?}"
        );
    }

    /// And the converse: a second row claiming to be the model would make it
    /// ambiguous which rules are in force.
    #[tokio::test]
    async fn only_the_reserved_id_may_carry_declarative_ext() {
        let (policy_ks, audit_ks, _d) = keyspaces().await;
        let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
        let err = upsert_policy(
            &policy_ks,
            &audit_ks,
            &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_ks, _d) = keyspaces().await;
        let err = upsert_policy(
            &policy_ks,
            &audit_ks,
            &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:?}"
        );
    }

    /// Whoever can write policy can delete the rule that gates them.
    #[tokio::test]
    async fn writing_policy_is_super_admin_only() {
        let (policy_ks, audit_ks, _d) = keyspaces().await;
        let err = upsert_policy(
            &policy_ks,
            &audit_ks,
            &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_ks, _d) = keyspaces().await;
        let rules = vec![ApprovalRule::reauth(ACL_GRANT)];
        upsert_policy(
            &policy_ks,
            &audit_ks,
            &super_admin(),
            declarative_body(&rules, None),
            "test",
        )
        .await
        .unwrap();

        let err = upsert_policy(
            &policy_ks,
            &audit_ks,
            &super_admin(),
            UpsertPolicyBody {
                // The row is at 1 now; a second operator still holding 0.
                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:?}");
    }

    /// Deleting the baseline would make the PDP deny every task the operator's
    /// own policies do not name, and it is not reinstalled while other rows
    /// exist.
    #[tokio::test]
    async fn the_baseline_cannot_be_deleted() {
        let (policy_ks, audit_ks, _d) = keyspaces().await;
        vta_policy::install_default_policy(&policy_ks, "2026-08-09T00:00:00Z")
            .await
            .unwrap();
        let err = delete_policy(
            &policy_ks,
            &audit_ks,
            &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:?}"
        );
    }
}