vtc-service 0.12.0

Service for Verifiable Trust Communities
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
//! Policy persistence model — spec §5.4 + §7.1.
//!
//! One [`Policy`] row per uploaded Rego module. Active modules are
//! pointed at by `active_policies:<purpose>` rows (single id per
//! purpose). M2.2 lands the model + storage CRUD; the upload /
//! activate / test endpoints layer on top in M2.3.
//!
//! ## What's stored, what's recompiled
//!
//! Spec §5.4 + plan §D3: only the Rego **source** + its **SHA-256**
//! hit fjall. Compiled bytecode is reconstructed at boot by walking
//! the active-pointer set and re-compiling. This keeps the on-disk
//! shape stable across regorus upgrades — bumping the interpreter
//! never invalidates stored rows.
//!
//! ## Wire shape vs storage shape
//!
//! The serialized form (`camelCase`) is also the JSON shape returned
//! by `GET /v1/policies/{id}` (M2.4). The audit envelope's
//! `PolicyActivated` data struct lifts `id` + `purpose` + `sha256` +
//! `version` from this same struct — see vti-common audit events.
//!
//! Phase 2 milestones layered on top:
//! - M2.3 — upload + activate + test endpoints.
//! - M2.5 — bundled default policies (join, removal, personhood, …)
//!   land as [`Policy`] rows at boot if no row exists for the
//!   purpose.
//! - M2.6 / M2.7 — `join.rego` + `removal.rego` consume the active
//!   pointer.
//! - M2.17 — audit variants `PolicyUploaded` + `PolicyActivated`
//!   reference [`Policy::id`] / [`Policy::sha256`].

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// One uploaded Rego policy.
///
/// Identity is by [`Self::id`] (UUID v4, allocated server-side at
/// upload time). `(purpose, version)` is unique across the
/// keyspace: every upload for a purpose bumps `version` by 1 so
/// historical rows stay reachable for audit even after a newer
/// upload supersedes them.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct Policy {
    /// Server-allocated UUID. Stable across activations + renames.
    pub id: Uuid,
    /// What this policy decides — see [`PolicyPurpose`].
    pub purpose: PolicyPurpose,
    /// The full Rego source. Bounded by [`POLICY_SOURCE_MAX_BYTES`]
    /// at the route layer (M2.3).
    pub rego_source: String,
    /// SHA-256 over the source bytes. Used by audit + the trust-task
    /// upload echo; computed alongside compilation in
    /// [`super::engine::compile`] so the hash + bytecode stay
    /// lockstep. Wire shape is a 64-char lowercase-hex string so
    /// operators + audit log consumers don't have to read a 32-element
    /// byte array — see [`hex32`] for the (de)serialization adapter.
    #[serde(with = "hex32")]
    pub sha256: [u8; 32],
    /// When this version was activated — `None` if it has never
    /// been the live policy for its purpose. M2.3's activate
    /// endpoint stamps this on every flip; archived rows retain
    /// the last activation time for audit context.
    #[serde(default)]
    pub activated_at: Option<DateTime<Utc>>,
    /// DID of the operator who uploaded this revision. Recorded
    /// for audit + the `PolicyUploaded` envelope's actor field
    /// (the audit envelope's `actor` is already this DID; this
    /// field exists so the row itself remains self-describing
    /// when read in isolation).
    pub author_did: String,
    /// Wall-clock time the row hit fjall.
    pub created_at: DateTime<Utc>,
    /// Monotone per-`purpose` counter. The first upload for any
    /// purpose is `1`. Audit + the operator UX use this to label
    /// historical revisions ("removal.rego v3 archived").
    pub version: u32,
    /// Operator-supplied module name and description (canonical
    /// `PolicyModule.name` / `.description`; `name` is required there).
    ///
    /// `#[serde(default)]` keeps rows written before these fields
    /// deserializable; they fall back to the purpose for `name`, which
    /// is the only stable human-meaningful identifier such a row has.
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
}

/// Hard cap on the bytes-on-disk size of [`Policy::rego_source`].
/// 64 KiB is comfortably above every default policy the workspace
/// ships in M2.5 (the largest, `join.rego`, is ~3 KiB) but small
/// enough that a malicious upload can't bloat the keyspace. M2.3
/// rejects payloads above this with 413.
pub const POLICY_SOURCE_MAX_BYTES: usize = 64 * 1024;

/// What a policy decides. The keyspace `active_policies:<purpose>`
/// has at most one row per variant; the active pointer is a
/// per-purpose singleton.
///
/// Wire shape is `camelCase` ASCII (`join`, `removal`,
/// `crossCommunityRoles`) — operators wire purposes into REST
/// payloads + the policies CLI verbs.
///
/// The workspace ships twelve purposes (spec §7.1 names the first ten). They
/// split into five groups:
/// - **Membership lifecycle**: [`Self::Join`], [`Self::Removal`],
///   [`Self::Personhood`].
/// - **Discoverability**: [`Self::Registry`], [`Self::Directory`],
///   [`Self::DirectoryWrite`].
/// - **Authorization**: [`Self::RoleDefinitions`], [`Self::RoleChange`],
///   [`Self::CrossCommunityRoles`],
///   [`Self::CrossCommunityRelationships`], [`Self::Relationships`].
/// - **Hosting**: [`Self::Rooms`].
/// - **Vetting**: [`Self::VetterEligibility`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub enum PolicyPurpose {
    Join,
    Removal,
    Personhood,
    Registry,
    Directory,
    RoleDefinitions,
    CrossCommunityRoles,
    CrossCommunityRelationships,
    Relationships,
    /// In-place change of a member's role — the role-change ceremony
    /// (pipeline `vtc.role_change`). The one ceremony whose `allow`
    /// may grant `admin`, gated by a verified step-up. Distinct from
    /// [`Self::RoleDefinitions`] (the role→permission matrix).
    RoleChange,
    /// Who may **create a data room** on this community (`vtc.rooms`).
    ///
    /// Creation only. Once a room exists, every operation on it is authorized
    /// by credentials the room itself issued and this community's roster has
    /// no say — invariant I5 of the data-rooms design, and what lets a room
    /// change hosts. This purpose decides the narrower question a host is
    /// unambiguously entitled to decide: whether to lend its disk.
    Rooms,
    /// Which members the automatic vetter-grant sweep names vetters
    /// (`vtc.vetter_eligibility`). Evaluated per active member when automatic
    /// grants are on: `allow` grants a member without a live grant, `deny`
    /// revokes the grants the sweep issued. Wire form `vetterEligibility`, like
    /// every purpose; the package spells it `vetter_eligibility`.
    VetterEligibility,
    /// What this community allows inside the forge namespaces it governs
    /// (`vtc.git_namespace`, the `git-ns/*` Trust Tasks). Evaluated only after the
    /// rights model's fixed rules pass, and able only to refuse — it narrows,
    /// never widens. Also carries the community's `settings`
    /// (`cascade_on_departure`, …). Wire form `gitNamespace`.
    GitNamespace,
    /// Whether an administrator may **admit a DID directly** — add a member
    /// without a join request (`vtc/members/admit/0.1`), or make an existing
    /// ACL entry with no member record a membership
    /// (`vtc/members/make-membership/0.1`) — and whether it needs another
    /// administrator's approval first (`vtc.admission`). `allow` admits now,
    /// `refer` parks it in the action list for the other holders of
    /// `vtc.members.manage`, `deny` refuses it. Wire form `admission`.
    Admission,
    /// What a directory card may hold (`vtc.directory_write`): evaluated over a
    /// member's `vtc/directory/card/update` and an administrator's
    /// `card/assert`, after the host's own checks. It may refuse a value — a
    /// nickname already taken, a link to a domain the community does not allow
    /// — and nothing else: it never widens a visibility, and a change that only
    /// narrows never reaches it. Wire form `directoryWrite`.
    DirectoryWrite,
}

impl PolicyPurpose {
    /// Every purpose variant, in declaration order. Used by the
    /// boot-time default-policy loader (M2.5) so missing rows can
    /// be filled from the bundled defaults without listing each
    /// purpose explicitly at the call site.
    pub const ALL: [PolicyPurpose; 15] = [
        PolicyPurpose::Join,
        PolicyPurpose::Removal,
        PolicyPurpose::Personhood,
        PolicyPurpose::Registry,
        PolicyPurpose::Directory,
        PolicyPurpose::RoleDefinitions,
        PolicyPurpose::CrossCommunityRoles,
        PolicyPurpose::CrossCommunityRelationships,
        PolicyPurpose::Relationships,
        PolicyPurpose::RoleChange,
        PolicyPurpose::Rooms,
        PolicyPurpose::VetterEligibility,
        PolicyPurpose::GitNamespace,
        PolicyPurpose::Admission,
        PolicyPurpose::DirectoryWrite,
    ];

    /// Whether this purpose decides **authority** — who becomes, stays or stops
    /// being someone with power in the community: a role change, a removal, a
    /// join, a foreign role's recognition, a git namespace right.
    ///
    /// Changing such a policy is itself a change of authority, so it takes the
    /// same second party a grant of unrestricted admin does (**VTI-VTC-022**;
    /// `vtc-action-list.md` §7b item 3). The remaining purposes are community
    /// rules, gated by role only.
    #[must_use]
    pub fn decides_authority(self) -> bool {
        matches!(
            self,
            PolicyPurpose::RoleChange
                | PolicyPurpose::Removal
                | PolicyPurpose::Join
                | PolicyPurpose::CrossCommunityRoles
                | PolicyPurpose::GitNamespace
                // Who is admitted without vetting is who becomes a member.
                | PolicyPurpose::Admission
        )
    }

    /// Whether this purpose decides **who this community recognises as a
    /// person** — the personhood policy. Not authority: recognition confers no
    /// power in the community, so holding `vtc.policy.admin @
    /// policy:personhood` is not authority-conferring
    /// ([`crate::acl::Capability::is_authority_conferring`] reads
    /// [`Self::decides_authority`] alone). But changing it alters the
    /// community's trust semantics (VTI-CMP-090), so it is held for another
    /// administrator's consent all the same (**VTI-APV-023**).
    #[must_use]
    pub fn decides_recognition(self) -> bool {
        matches!(self, PolicyPurpose::Personhood)
    }

    /// Whether replacing or activating this purpose's policy is held for
    /// another administrator's consent: it decides authority (**VTI-VTC-022**)
    /// or recognition (**VTI-APV-023**).
    #[must_use]
    pub fn requires_consent(self) -> bool {
        self.decides_authority() || self.decides_recognition()
    }

    /// Lowercase camelCase wire form of this purpose. Stable wire
    /// (operators script around it); matches the serde
    /// representation.
    pub fn as_str(self) -> &'static str {
        match self {
            PolicyPurpose::Join => "join",
            PolicyPurpose::Removal => "removal",
            PolicyPurpose::Personhood => "personhood",
            PolicyPurpose::Registry => "registry",
            PolicyPurpose::Directory => "directory",
            PolicyPurpose::RoleDefinitions => "roleDefinitions",
            PolicyPurpose::CrossCommunityRoles => "crossCommunityRoles",
            PolicyPurpose::CrossCommunityRelationships => "crossCommunityRelationships",
            PolicyPurpose::Relationships => "relationships",
            PolicyPurpose::RoleChange => "roleChange",
            PolicyPurpose::Rooms => "rooms",
            PolicyPurpose::VetterEligibility => "vetterEligibility",
            PolicyPurpose::GitNamespace => "gitNamespace",
            PolicyPurpose::Admission => "admission",
            PolicyPurpose::DirectoryWrite => "directoryWrite",
        }
    }

    /// The Rego package an uploaded policy for this purpose must live
    /// in, for the four ceremony purposes the decision pipeline probes
    /// by a fixed package (`data.<pkg>.decision`). A module compiled
    /// into the *wrong* package compiles cleanly but evaluates to
    /// `undefined` at decision time, which the host reads as a silent
    /// default-deny for that whole ceremony — so the package is part of
    /// the upload contract, validated by
    /// [`crate::policy::validate_purpose_package`].
    ///
    /// `None` for purposes without a single pinned decision package
    /// (registry, personhood, role-definitions, …) — those are not
    /// package-validated at upload.
    pub fn expected_package(self) -> Option<&'static str> {
        match self {
            PolicyPurpose::Join => Some("vtc.join"),
            PolicyPurpose::Removal => Some("vtc.removal"),
            PolicyPurpose::RoleChange => Some("vtc.role_change"),
            PolicyPurpose::Directory => Some("vtc.directory"),
            // Probed by a fixed package like the ceremony purposes: a rooms
            // policy compiled into the wrong one evaluates to `undefined`,
            // which this service refuses rather than reads as consent — but an
            // operator would see every registration denied with no clue why.
            PolicyPurpose::Rooms => Some("vtc.rooms"),
            // Probed by a fixed package: a policy in any other package answers
            // nothing, which the sweep logs and acts on for no one.
            PolicyPurpose::VetterEligibility => Some("vtc.vetter_eligibility"),
            // Probed by a fixed package: a policy in any other package answers
            // nothing, which refuses every git-namespace request — closed, and
            // mysterious without this check at upload.
            PolicyPurpose::GitNamespace => Some("vtc.git_namespace"),
            // Probed by a fixed package, as the ceremony purposes are: a
            // policy in any other package answers nothing, which refuses every
            // direct admission.
            PolicyPurpose::Admission => Some("vtc.admission"),
            // Probed by a fixed package: a policy in any other package answers
            // nothing, which refuses every card write that widens.
            PolicyPurpose::DirectoryWrite => Some("vtc.directory_write"),
            _ => None,
        }
    }
}

/// `[u8; 32]` ↔ 64-char lowercase-hex string serde adapter. Used by
/// [`Policy::sha256`] so the wire form is human-readable and matches
/// what `sha256sum policy.rego` prints. Hand-rolled to avoid pulling
/// `serde_with` into the workspace for a single field.
mod hex32 {
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        s.serialize_str(&hex::encode(bytes))
    }

    pub fn deserialize<'de, D>(d: D) -> Result<[u8; 32], D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(d)?;
        let v = hex::decode(&s).map_err(serde::de::Error::custom)?;
        v.try_into()
            .map_err(|got: Vec<u8>| serde::de::Error::invalid_length(got.len(), &"32 bytes"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use sha2::{Digest, Sha256};

    #[test]
    fn purpose_wire_shape_round_trips() {
        // Every variant serializes to the documented `camelCase`
        // string and deserializes back. Lock the shape down at
        // model level — the audit envelope + the route layer both
        // depend on it.
        let cases = [
            (PolicyPurpose::Join, json!("join")),
            (PolicyPurpose::Removal, json!("removal")),
            (PolicyPurpose::Personhood, json!("personhood")),
            (PolicyPurpose::Registry, json!("registry")),
            (PolicyPurpose::Directory, json!("directory")),
            (PolicyPurpose::RoleDefinitions, json!("roleDefinitions")),
            (
                PolicyPurpose::CrossCommunityRoles,
                json!("crossCommunityRoles"),
            ),
            (
                PolicyPurpose::CrossCommunityRelationships,
                json!("crossCommunityRelationships"),
            ),
            (PolicyPurpose::Relationships, json!("relationships")),
            (PolicyPurpose::RoleChange, json!("roleChange")),
            (PolicyPurpose::VetterEligibility, json!("vetterEligibility")),
            (PolicyPurpose::GitNamespace, json!("gitNamespace")),
            (PolicyPurpose::Admission, json!("admission")),
            (PolicyPurpose::DirectoryWrite, json!("directoryWrite")),
        ];
        for (purpose, wire) in cases {
            assert_eq!(serde_json::to_value(purpose).unwrap(), wire);
            let parsed: PolicyPurpose = serde_json::from_value(wire.clone()).unwrap();
            assert_eq!(parsed, purpose);
            assert_eq!(purpose.as_str(), wire.as_str().unwrap());
        }
    }

    #[test]
    fn policy_round_trips_through_json() {
        let source = "package vtc.test\nimport rego.v1\n";
        let sha256: [u8; 32] = Sha256::digest(source.as_bytes()).into();
        let policy = Policy {
            id: Uuid::from_u128(0xdead_beef_0000_0001_0000_0000_0000_0000),
            purpose: PolicyPurpose::Removal,
            rego_source: source.into(),
            sha256,
            activated_at: None,
            author_did: "did:key:zAdmin".into(),
            created_at: Utc::now(),
            version: 7,
            name: None,
            description: None,
        };
        let json = serde_json::to_value(&policy).unwrap();
        assert!(json["regoSource"].is_string());
        assert_eq!(json["purpose"], "removal");
        assert_eq!(json["version"], 7);
        let parsed: Policy = serde_json::from_value(json).unwrap();
        assert_eq!(parsed, policy);
    }

    #[test]
    fn all_covers_every_variant() {
        // `ALL` is consumed by M2.5's default-policy loader; if a
        // new variant lands and someone forgets to add it here,
        // the bundled default would silently never load. Drive the
        // count + exhaustiveness assertion off the same constant
        // so a missed entry surfaces at test time.
        assert_eq!(PolicyPurpose::ALL.len(), 15);
        for purpose in PolicyPurpose::ALL {
            // Compiles iff the match is total — `as_str` exhaustively
            // matches every variant; this exists so the assertion
            // sticks if anyone tries to special-case a variant out.
            let _ = purpose.as_str();
        }
    }
}