Skip to main content

vta_support/
contexts.rs

1pub use vta_sdk::contexts::ContextRecord;
2
3use chrono::Utc;
4use vta_sdk::context_path::parent_path;
5use vta_sdk::context_policy::ContextPolicy;
6
7use vti_common::error::AppError;
8use vti_common::store::KeyspaceHandle;
9
10fn ctx_key(id: &str) -> String {
11    format!("ctx:{id}")
12}
13
14/// Retrieve a context by ID.
15pub async fn get_context(ks: &KeyspaceHandle, id: &str) -> Result<Option<ContextRecord>, AppError> {
16    ks.get(ctx_key(id)).await
17}
18
19/// Resolve the *effective* [`ContextPolicy`] for `context_id` by intersecting
20/// the policies of every context on the path root→leaf (field-wise, additive
21/// narrowing — see [`ContextPolicy`] docs). Contexts with no policy (or a
22/// missing record) contribute nothing; a chain that constrains nothing resolves
23/// to [`ContextPolicy::unrestricted`], i.e. today's behaviour.
24///
25/// Because enforcement always resolves the whole chain, a permissive policy
26/// written at a child level can never widen what an ancestor allows.
27pub async fn effective_context_policy(
28    ks: &KeyspaceHandle,
29    context_id: &str,
30) -> Result<ContextPolicy, AppError> {
31    // Collect ids leaf→root, then resolve root→leaf.
32    let mut ids: Vec<String> = Vec::new();
33    let mut cur: Option<String> = Some(context_id.to_string());
34    while let Some(id) = cur {
35        cur = parent_path(&id).map(str::to_string);
36        ids.push(id);
37    }
38    ids.reverse();
39
40    let mut policies: Vec<ContextPolicy> = Vec::new();
41    for id in &ids {
42        if let Some(rec) = get_context(ks, id).await?
43            && let Some(policy) = rec.context_policy
44        {
45            policies.push(policy);
46        }
47    }
48    Ok(ContextPolicy::resolve(policies.iter()))
49}
50
51/// Enforce a per-day operation quota for a context (the `quotas` arm of
52/// [`ContextPolicy`]). Atomically counts this operation in the current UTC day
53/// and returns `Forbidden` once the ceiling is reached. Counters are keyed by
54/// day (`quota:{context}:{op}:{YYYY-MM-DD}`) in the contexts keyspace, so they
55/// roll over automatically and stale days are inert (a future sweep can prune
56/// them). `allocate_u32` is 0-indexed, so the call that returns `limit` is the
57/// `(limit+1)`-th — rejecting it allows exactly `limit` operations per day.
58pub async fn enforce_daily_quota(
59    ks: &KeyspaceHandle,
60    context_id: &str,
61    op_class: &str,
62    limit: u64,
63) -> Result<(), AppError> {
64    let day = Utc::now().format("%Y-%m-%d");
65    let key = format!("quota:{context_id}:{op_class}:{day}");
66    let slot = vti_common::store::counter::allocate_u32(ks, &key).await?;
67    if u64::from(slot) >= limit {
68        return Err(AppError::Forbidden(format!(
69            "daily quota exceeded for {op_class} in context {context_id} ({limit}/day)"
70        )));
71    }
72    Ok(())
73}
74
75/// Store (create or overwrite) a context record.
76pub async fn store_context(ks: &KeyspaceHandle, record: &ContextRecord) -> Result<(), AppError> {
77    ks.insert(ctx_key(&record.id), record).await
78}
79
80/// Store a NEW context record, claiming the id atomically. Returns
81/// `false` (storing nothing) when the id is already taken — creation
82/// paths must treat that as a Conflict, never overwrite: last-writer-
83/// wins on a context record silently re-points its BIP-32 base path.
84pub async fn store_new_context(
85    ks: &KeyspaceHandle,
86    record: &ContextRecord,
87) -> Result<bool, AppError> {
88    ks.insert_if_absent(ctx_key(&record.id), record).await
89}
90
91/// Delete a context by ID.
92pub async fn delete_context(ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
93    ks.remove(ctx_key(id)).await
94}
95
96/// List all context records.
97///
98/// A row that fails to deserialize is skipped with a warning rather than
99/// aborting the whole listing — one corrupt context must not break
100/// context management for every other context.
101pub async fn list_contexts(ks: &KeyspaceHandle) -> Result<Vec<ContextRecord>, AppError> {
102    let raw = ks.prefix_iter_raw("ctx:").await?;
103    let mut records = Vec::with_capacity(raw.len());
104    let mut skipped = 0usize;
105    for (key, value) in raw {
106        match serde_json::from_slice::<ContextRecord>(&value) {
107            Ok(record) => records.push(record),
108            Err(e) => {
109                skipped += 1;
110                tracing::warn!(
111                    key = %String::from_utf8_lossy(&key),
112                    error = %e,
113                    "skipping undeserializable context row in list_contexts"
114                );
115            }
116        }
117    }
118    if skipped > 0 {
119        tracing::warn!(skipped, "list_contexts skipped corrupt rows");
120    }
121    Ok(records)
122}
123
124/// Allocate the next context index and return `(index, base_path)`.
125///
126/// Allocate the next BIP-32 base path under `base_prefix`, bumping the counter
127/// at `counter_key`.
128///
129/// Top-level contexts use [`CONTEXT_KEY_BASE`] + the legacy `ctx_counter` key
130/// (so existing indices are preserved). A sub-context passes its **parent's**
131/// `base_path` as the prefix and a **per-parent** counter key, so each parent
132/// allocates its children independently and the derivation path nests:
133/// `{parent.base_path}/<child>'`.
134pub async fn allocate_context_index(
135    ks: &KeyspaceHandle,
136    base_prefix: &str,
137    counter_key: &str,
138) -> Result<(u32, String), AppError> {
139    // Serialised via the shared counter allocator: two concurrent
140    // context creations handed the same index would share an entire
141    // BIP-32 subtree — identical private keys across trust boundaries.
142    let current = vti_common::store::counter::allocate_u32(ks, counter_key).await?;
143    let base_path = format!("{base_prefix}/{current}'");
144    Ok((current, base_path))
145}
146
147/// Validate a **single** context id segment.
148///
149/// Deliberately stricter than
150/// [`vti_common::identifier::validate_identifier`], which also admits
151/// uppercase, `.` and `_`. Context ids surface in DID paths, derivation
152/// labels and operator commands, so they are held to one canonical
153/// lowercase-slug spelling rather than several that differ only by case
154/// or punctuation.
155///
156/// **Segment-only.** A hierarchical id (`acme/eng`) is the *stored*
157/// form, built by `context_path::child_path` from an already-validated
158/// parent plus a leaf validated here. Never call this on a full path —
159/// the `/` would be rejected.
160pub fn validate_slug(id: &str) -> Result<(), AppError> {
161    if id.is_empty() {
162        return Err(AppError::Validation("context id cannot be empty".into()));
163    }
164    if id.len() > 64 {
165        return Err(AppError::Validation(
166            "context id must be 64 characters or fewer".into(),
167        ));
168    }
169    if !id
170        .chars()
171        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
172    {
173        return Err(AppError::Validation(
174            "context id must contain only lowercase alphanumeric characters and hyphens".into(),
175        ));
176    }
177    if id.starts_with('-') || id.ends_with('-') {
178        return Err(AppError::Validation(
179            "context id must not start or end with a hyphen".into(),
180        ));
181    }
182    Ok(())
183}
184
185/// Create a new top-level application context and store it.
186///
187/// Applies the same [`validate_slug`] gate as the authenticated
188/// `operations::contexts::create_context`. Both must, or setup-time
189/// callers (`setup::from_toml`, `tee::did_autogen`) could persist ids
190/// the operator-facing API would later refuse — leaving a deployment
191/// whose own CLI rejects a context it already has.
192pub async fn create_context(
193    contexts_ks: &KeyspaceHandle,
194    id: &str,
195    name: &str,
196) -> Result<ContextRecord, Box<dyn std::error::Error>> {
197    validate_slug(id).map_err(|e| format!("{e}"))?;
198    let (index, base_path) = allocate_context_index(contexts_ks, CONTEXT_KEY_BASE, "ctx_counter")
199        .await
200        .map_err(|e| format!("{e}"))?;
201    let now = Utc::now();
202    let record = ContextRecord {
203        id: id.to_string(),
204        name: name.to_string(),
205        did: None,
206        description: None,
207        parent: None,
208        base_path,
209        index,
210        created_at: now,
211        updated_at: now,
212        context_policy: None,
213    };
214    if !store_new_context(contexts_ks, &record)
215        .await
216        .map_err(|e| format!("{e}"))?
217    {
218        // The allocated counter slot is intentionally left as a gap —
219        // counters skip forward on a lost race, they never reuse.
220        return Err(format!("context already exists: {id}").into());
221    }
222    Ok(record)
223}
224
225/// Base path for application context keys.
226pub const CONTEXT_KEY_BASE: &str = "m/26'/2'";
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use vti_common::config::StoreConfig;
232    use vti_common::store::Store;
233
234    fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
235        let dir = tempfile::tempdir().expect("tempdir");
236        let store = Store::open(&StoreConfig {
237            data_dir: dir.path().to_path_buf(),
238        })
239        .expect("open store");
240        (
241            store.keyspace(vta_keyspaces::CONTEXTS).expect("keyspace"),
242            dir,
243        )
244    }
245
246    #[tokio::test]
247    async fn daily_quota_allows_up_to_limit_then_forbids() {
248        let (ks, _dir) = temp_ks();
249        // Limit 2: the first two operations pass, the third is refused.
250        enforce_daily_quota(&ks, "sales", "sign", 2).await.unwrap();
251        enforce_daily_quota(&ks, "sales", "sign", 2).await.unwrap();
252        let err = enforce_daily_quota(&ks, "sales", "sign", 2).await;
253        assert!(matches!(err, Err(AppError::Forbidden(_))), "{err:?}");
254
255        // A different op-class (and a different context) has an independent
256        // counter.
257        enforce_daily_quota(&ks, "sales", "vault/release", 1)
258            .await
259            .unwrap();
260        let err2 = enforce_daily_quota(&ks, "sales", "vault/release", 1).await;
261        assert!(matches!(err2, Err(AppError::Forbidden(_))), "{err2:?}");
262        enforce_daily_quota(&ks, "eng", "sign", 2).await.unwrap();
263    }
264
265    /// Regression test for the context-index race: N concurrent
266    /// allocations must yield N distinct base paths. Two contexts
267    /// handed the same index would share an entire BIP-32 subtree —
268    /// identical private keys across trust boundaries.
269    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
270    async fn allocate_context_index_is_collision_free_under_concurrency() {
271        let (ks, _dir) = temp_ks();
272
273        let n = 64usize;
274        let mut handles = Vec::with_capacity(n);
275        for _ in 0..n {
276            let ks = ks.clone();
277            handles.push(tokio::spawn(async move {
278                allocate_context_index(&ks, CONTEXT_KEY_BASE, "ctx_counter")
279                    .await
280                    .expect("alloc")
281            }));
282        }
283        let mut paths = std::collections::HashSet::with_capacity(n);
284        for h in handles {
285            let (_, base_path) = h.await.expect("join");
286            assert!(
287                paths.insert(base_path.clone()),
288                "duplicate context base path {base_path}"
289            );
290        }
291        assert_eq!(paths.len(), n);
292    }
293
294    /// Concurrent creates with the same id: exactly one may win; the
295    /// losers must not overwrite the winner's record (last-writer-wins
296    /// would silently re-point the context's BIP-32 base path).
297    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
298    async fn concurrent_same_id_creates_admit_exactly_one() {
299        let (ks, _dir) = temp_ks();
300
301        let mut handles = Vec::new();
302        for _ in 0..16 {
303            let ks = ks.clone();
304            handles.push(tokio::spawn(async move {
305                create_context(&ks, "contested", "Contested").await.ok()
306            }));
307        }
308        let mut winners = Vec::new();
309        for h in handles {
310            if let Some(rec) = h.await.expect("join") {
311                winners.push(rec);
312            }
313        }
314        assert_eq!(winners.len(), 1, "exactly one same-id create may win");
315
316        let stored = get_context(&ks, "contested")
317            .await
318            .expect("get")
319            .expect("record exists");
320        assert_eq!(
321            stored.base_path, winners[0].base_path,
322            "stored record must be the winner's — no overwrite by losers"
323        );
324    }
325
326    #[test]
327    fn validate_slug_accepts_canonical_shapes() {
328        for ok in ["a", "vta", "trust-registry", "ctx-1", "a1-b2-c3"] {
329            validate_slug(ok).unwrap_or_else(|e| panic!("{ok:?} should be valid: {e}"));
330        }
331        validate_slug(&"a".repeat(64)).expect("64 chars is the limit, not over it");
332    }
333
334    #[test]
335    fn validate_slug_rejects_non_canonical_shapes() {
336        // Uppercase, `.` and `_` are accepted by the *looser*
337        // `validate_identifier` used for parent path segments. Context
338        // ids are held to the narrower slug rule, so these must fail
339        // here even though they are legal identifiers elsewhere.
340        for bad in [
341            "MyApp", "ctx.v2", "my_app", "-lead", "trail-", "", "a/b", "a b",
342        ] {
343            assert!(
344                validate_slug(bad).is_err(),
345                "{bad:?} must be rejected as a context id"
346            );
347        }
348        assert!(validate_slug(&"a".repeat(65)).is_err(), "65 chars is over");
349    }
350
351    /// The setup-time path (`setup::from_toml`, `tee::did_autogen`)
352    /// must not be able to persist an id that the authenticated
353    /// `operations::contexts::create_context` would refuse — otherwise
354    /// a deployment ends up with a context its own CLI rejects.
355    #[tokio::test]
356    async fn low_level_create_context_enforces_the_slug_rule() {
357        let (ks, _dir) = temp_ks();
358
359        let err = create_context(&ks, "MyApp", "My App")
360            .await
361            .expect_err("non-slug id must be refused");
362        assert!(
363            err.to_string().contains("lowercase"),
364            "error should name the rule, got: {err}"
365        );
366
367        assert!(
368            get_context(&ks, "MyApp").await.expect("get").is_none(),
369            "a rejected id must not have been persisted"
370        );
371
372        create_context(&ks, "myapp", "My App")
373            .await
374            .expect("the slug form is accepted");
375    }
376}