astrid_core/profile/mod.rs
1//! Per-principal profile: enablement, auth, resource quotas, egress policy.
2//!
3//! A [`PrincipalProfile`] is loaded from
4//! `~/.astrid/home/{principal}/.config/profile.toml` and describes the static
5//! policy for a single principal: whether it is enabled, which authentication
6//! methods it supports, its group memberships, its resource quotas, and its
7//! egress / process-spawn policy.
8//!
9//! This module is **Layer 2** of the multi-tenancy work (see parent issue
10//! #653). It is pure data plumbing — the kernel does not yet consume these
11//! values in `invoke_interceptor`. Layer 3 will wire quota enforcement;
12//! Layer 6 will expose management IPC; the CLI surface lives in #657.
13//!
14//! # Behavior
15//!
16//! - Missing file → [`PrincipalProfile::default`]. Fresh principals without a
17//! profile on disk get the permissive-ish defaults below (egress and
18//! process spawn default to empty → fail-closed).
19//! - Malformed TOML, unknown fields, failed validation, or a future
20//! `profile_version` → hard error. The operator must correct the file.
21//! - Save is atomic on Unix (write to `.tmp` with `0o600`, then `rename`).
22//!
23//! # Defaults
24//!
25//! - `max_memory_bytes` = 64 `MiB`
26//! - `max_timeout_secs` = 300 (5 min)
27//! - `max_ipc_throughput_bytes` = 10 `MiB`/s
28//! - `max_background_processes` = 8
29//! - `max_storage_bytes` = 1 `GiB`
30//! - `network.egress` = `[]` (no outbound)
31//! - `process.allow` = `[]` (no spawn)
32
33use std::io;
34use std::sync::OnceLock;
35
36use serde::{Deserialize, Serialize};
37use thiserror::Error;
38
39mod io_impl;
40mod validation;
41
42/// Current profile schema version. Bumped on breaking field changes.
43///
44/// Profiles on disk with a version greater than this constant are rejected
45/// by [`PrincipalProfile::validate`] — a forward-dated profile would otherwise
46/// be silently truncated to whatever fields this binary understands.
47pub const CURRENT_PROFILE_VERSION: u32 = 1;
48
49/// Default per-principal memory ceiling in bytes (64 `MiB`).
50pub const DEFAULT_MAX_MEMORY_BYTES: u64 = 64 * 1024 * 1024;
51/// Default per-invocation wall-clock timeout in seconds (5 minutes).
52pub const DEFAULT_MAX_TIMEOUT_SECS: u64 = 300;
53/// Default per-principal IPC throughput ceiling in bytes/sec (10 `MiB`/s).
54pub const DEFAULT_MAX_IPC_THROUGHPUT_BYTES: u64 = 10 * 1024 * 1024;
55/// Default max concurrent background processes per principal.
56pub const DEFAULT_MAX_BACKGROUND_PROCESSES: u32 = 8;
57/// Default per-principal storage ceiling in bytes (1 `GiB`).
58pub const DEFAULT_MAX_STORAGE_BYTES: u64 = 1024 * 1024 * 1024;
59
60/// Default per-principal CPU rate ceiling in wasmtime fuel units per second
61/// (2e9 ≈ a 2 GHz-equivalent guest-instruction budget).
62///
63/// Fuel meters **executed guest instructions** independently of host-call
64/// yields. This per-principal rate is the quota surface for CPU attribution
65/// and future per-invocation budgeting; the capsule engine records the exact
66/// fuel each interceptor call consumes (per-principal ledger) against it. The
67/// run-loop CPU **bound** itself is enforced by the capsule engine's epoch
68/// interrupt (a no-recv spinner is trapped after a few windows, a recv loop
69/// never is), not by this rate directly. Operators tier this up per-principal;
70/// admin is exempt by capability, not by quota value, so there is no
71/// "unlimited" sentinel here.
72pub const DEFAULT_MAX_CPU_FUEL_PER_SEC: u64 = 2_000_000_000;
73
74/// Absolute upper bound on [`Quotas::max_timeout_secs`] (24 hours).
75///
76/// A sanity guard against runaway invocations — the enforcement layer may
77/// impose a tighter ceiling.
78pub const TIMEOUT_SECS_UPPER_BOUND: u64 = 86_400;
79/// Absolute upper bound on [`Quotas::max_background_processes`].
80pub const BACKGROUND_PROCESSES_UPPER_BOUND: u32 = 256;
81
82/// Maximum length of a single entry in [`PrincipalProfile::groups`].
83pub const MAX_GROUP_NAME_LEN: usize = 64;
84
85/// Result alias for profile operations.
86pub type ProfileResult<T> = Result<T, ProfileError>;
87
88/// Errors raised by [`PrincipalProfile`] load, save, and validation.
89#[derive(Debug, Error)]
90pub enum ProfileError {
91 /// Filesystem IO failed (read, write, rename, `create_dir_all`).
92 #[error("profile io error: {0}")]
93 Io(#[from] io::Error),
94 /// Profile TOML failed to deserialize (syntax or `deny_unknown_fields`).
95 #[error("profile parse error: {0}")]
96 Parse(#[from] toml::de::Error),
97 /// Profile failed to serialize back to TOML.
98 #[error("profile serialize error: {0}")]
99 Serialize(#[from] toml::ser::Error),
100 /// Profile value failed semantic validation.
101 #[error("profile validation error: {0}")]
102 Invalid(String),
103}
104
105/// Per-principal profile: enablement, auth, resource quotas, egress policy.
106///
107/// Loaded from `~/.astrid/home/{principal}/.config/profile.toml`. A missing
108/// file yields [`PrincipalProfile::default`]. A malformed, invalid, or
109/// future-versioned file is a hard error.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct PrincipalProfile {
113 /// Schema version. Bumped on breaking field changes.
114 ///
115 /// Values above [`CURRENT_PROFILE_VERSION`] are rejected at load time.
116 #[serde(default = "current_profile_version")]
117 pub profile_version: u32,
118
119 /// Master enable switch. When `false`, the kernel will refuse every
120 /// invocation for this principal regardless of capabilities.
121 #[serde(default = "default_true")]
122 pub enabled: bool,
123
124 /// Group memberships. Resolved to capability sets via
125 /// [`GroupConfig`](crate::GroupConfig).
126 #[serde(default)]
127 pub groups: Vec<String>,
128
129 /// Capability patterns granted directly to this principal, beyond the
130 /// capabilities inherited from the groups listed in
131 /// [`PrincipalProfile::groups`]. Each entry is validated against the
132 /// capability grammar (see
133 /// [`crate::capability_grammar::validate_capability`]) at load time.
134 #[serde(default)]
135 pub grants: Vec<String>,
136
137 /// Capability patterns explicitly denied to this principal. Revokes
138 /// have the highest precedence — a matching revoke overrides any
139 /// grant or group-inherited capability, including an `admin` group
140 /// membership. Entries are validated against the same grammar as
141 /// [`PrincipalProfile::grants`].
142 #[serde(default)]
143 pub revokes: Vec<String>,
144
145 /// Authentication configuration.
146 #[serde(default)]
147 pub auth: AuthConfig,
148
149 /// Network egress policy.
150 #[serde(default)]
151 pub network: NetworkConfig,
152
153 /// Process-spawn policy.
154 #[serde(default)]
155 pub process: ProcessConfig,
156
157 /// Resource quotas.
158 #[serde(default)]
159 pub quotas: Quotas,
160}
161
162/// Authentication methods a principal may use.
163///
164/// Closed enum so serde rejects typos (`passky`, `keyparr`) at load time
165/// rather than silently granting access via a method the authenticator
166/// does not understand. TOML / JSON wire form is the lowercase variant name.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum AuthMethod {
170 /// Ed25519 public-key authentication.
171 Keypair,
172 /// `WebAuthn` / FIDO2 passkey.
173 Passkey,
174 /// System-level authentication (e.g. peer UID over the kernel socket).
175 System,
176}
177
178/// Authentication configuration for a principal.
179#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct AuthConfig {
182 /// Accepted authentication methods. Serde rejects unknown variants.
183 #[serde(default)]
184 pub methods: Vec<AuthMethod>,
185
186 /// Public keys bound to this principal (encoding TBD; see Layer 5).
187 #[serde(default)]
188 pub public_keys: Vec<String>,
189}
190
191/// Network egress configuration for a principal.
192///
193/// Empty `egress` means no outbound traffic is permitted (fail-closed).
194#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct NetworkConfig {
197 /// Egress allow-list patterns.
198 ///
199 /// Exact pattern grammar is settled by Layer 5 (it will reuse the
200 /// capsule manifest net-pattern parser). This layer validates only
201 /// that entries are non-empty strings.
202 #[serde(default)]
203 pub egress: Vec<String>,
204}
205
206/// Process-spawn configuration for a principal.
207///
208/// Empty `allow` means the principal cannot spawn external processes.
209#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ProcessConfig {
212 /// Executables permitted for process spawn.
213 ///
214 /// Entries may be absolute paths or short names drawn from a sandbox
215 /// profile allowlist; the final grammar is pinned by Layer 5. This
216 /// layer validates only that entries are non-empty strings.
217 #[serde(default)]
218 pub allow: Vec<String>,
219}
220
221/// Per-principal resource quotas.
222///
223/// Enforcement happens in Layer 3. This struct only carries the values and
224/// rejects nonsense on load/save.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct Quotas {
228 /// Maximum resident memory in bytes. Must be > 0.
229 #[serde(default = "default_max_memory_bytes")]
230 pub max_memory_bytes: u64,
231
232 /// Maximum wall-clock time for a single invocation, in seconds.
233 ///
234 /// Must be in `1..=`[`TIMEOUT_SECS_UPPER_BOUND`].
235 #[serde(default = "default_max_timeout_secs")]
236 pub max_timeout_secs: u64,
237
238 /// Maximum IPC throughput in bytes/sec. Must be > 0.
239 #[serde(default = "default_max_ipc_throughput_bytes")]
240 pub max_ipc_throughput_bytes: u64,
241
242 /// Maximum concurrent background processes. Must be
243 /// `<=` [`BACKGROUND_PROCESSES_UPPER_BOUND`].
244 #[serde(default = "default_max_background_processes")]
245 pub max_background_processes: u32,
246
247 /// Maximum persistent storage in bytes. Must be > 0.
248 #[serde(default = "default_max_storage_bytes")]
249 pub max_storage_bytes: u64,
250
251 /// Maximum CPU rate in wasmtime fuel units per second. Must be > 0.
252 ///
253 /// Per-principal CPU attribution surface: the capsule engine meters the
254 /// exact fuel each interceptor invocation consumes and accumulates it per
255 /// principal against this rate (telemetry today, per-invocation budgeting
256 /// later). The run-loop CPU **bound** is enforced separately by the capsule
257 /// engine's epoch interrupt (a no-recv spinner traps after a few windows; a
258 /// recv loop never does), not by this value. A principal holding
259 /// [`CAP_RESOURCES_UNBOUNDED`](crate::CAP_RESOURCES_UNBOUNDED) (e.g. any
260 /// `admin`) is exempt from the run-loop bound regardless of this value.
261 #[serde(default = "default_max_cpu_fuel_per_sec")]
262 pub max_cpu_fuel_per_sec: u64,
263}
264
265// ── serde default helpers ────────────────────────────────────────────────
266
267fn current_profile_version() -> u32 {
268 CURRENT_PROFILE_VERSION
269}
270
271fn default_true() -> bool {
272 true
273}
274
275fn default_max_memory_bytes() -> u64 {
276 DEFAULT_MAX_MEMORY_BYTES
277}
278
279fn default_max_timeout_secs() -> u64 {
280 DEFAULT_MAX_TIMEOUT_SECS
281}
282
283fn default_max_ipc_throughput_bytes() -> u64 {
284 DEFAULT_MAX_IPC_THROUGHPUT_BYTES
285}
286
287fn default_max_background_processes() -> u32 {
288 DEFAULT_MAX_BACKGROUND_PROCESSES
289}
290
291fn default_max_storage_bytes() -> u64 {
292 DEFAULT_MAX_STORAGE_BYTES
293}
294
295fn default_max_cpu_fuel_per_sec() -> u64 {
296 DEFAULT_MAX_CPU_FUEL_PER_SEC
297}
298
299// ── Default impls ────────────────────────────────────────────────────────
300
301impl Default for PrincipalProfile {
302 fn default() -> Self {
303 Self {
304 profile_version: CURRENT_PROFILE_VERSION,
305 enabled: true,
306 groups: Vec::new(),
307 grants: Vec::new(),
308 revokes: Vec::new(),
309 auth: AuthConfig::default(),
310 network: NetworkConfig::default(),
311 process: ProcessConfig::default(),
312 quotas: Quotas::default(),
313 }
314 }
315}
316
317impl PrincipalProfile {
318 /// Borrow the process-global default profile.
319 ///
320 /// Layer 3's `effective_profile()` accessor returns `&PrincipalProfile`,
321 /// so it needs a stable reference to hand back when no per-invocation
322 /// profile has been set. Allocating a fresh [`Self::default`] per call
323 /// would cost an allocation on every hot-path accessor read; a static
324 /// reference is cheaper and safe because the default is immutable.
325 #[must_use]
326 pub fn default_ref() -> &'static Self {
327 static DEFAULT: OnceLock<PrincipalProfile> = OnceLock::new();
328 DEFAULT.get_or_init(Self::default)
329 }
330}
331
332impl Default for Quotas {
333 fn default() -> Self {
334 Self {
335 max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
336 max_timeout_secs: DEFAULT_MAX_TIMEOUT_SECS,
337 max_ipc_throughput_bytes: DEFAULT_MAX_IPC_THROUGHPUT_BYTES,
338 max_background_processes: DEFAULT_MAX_BACKGROUND_PROCESSES,
339 max_storage_bytes: DEFAULT_MAX_STORAGE_BYTES,
340 max_cpu_fuel_per_sec: DEFAULT_MAX_CPU_FUEL_PER_SEC,
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn default_is_permissive_but_fail_closed_egress() {
351 let p = PrincipalProfile::default();
352 assert_eq!(p.profile_version, CURRENT_PROFILE_VERSION);
353 assert!(p.enabled);
354 assert!(p.groups.is_empty());
355 assert!(p.grants.is_empty());
356 assert!(p.revokes.is_empty());
357 assert!(p.auth.methods.is_empty());
358 assert!(p.auth.public_keys.is_empty());
359 assert!(p.network.egress.is_empty(), "egress must fail-closed");
360 assert!(p.process.allow.is_empty(), "process spawn must fail-closed");
361 assert_eq!(p.quotas.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
362 assert_eq!(p.quotas.max_timeout_secs, DEFAULT_MAX_TIMEOUT_SECS);
363 assert_eq!(
364 p.quotas.max_ipc_throughput_bytes,
365 DEFAULT_MAX_IPC_THROUGHPUT_BYTES
366 );
367 assert_eq!(
368 p.quotas.max_background_processes,
369 DEFAULT_MAX_BACKGROUND_PROCESSES
370 );
371 assert_eq!(p.quotas.max_storage_bytes, DEFAULT_MAX_STORAGE_BYTES);
372 assert_eq!(p.quotas.max_cpu_fuel_per_sec, DEFAULT_MAX_CPU_FUEL_PER_SEC);
373 p.validate().expect("defaults validate");
374 }
375
376 #[test]
377 fn default_ref_matches_default_and_is_stable() {
378 let a = PrincipalProfile::default_ref();
379 let b = PrincipalProfile::default_ref();
380 // Same `OnceLock` value across calls — stable pointer.
381 assert!(std::ptr::eq(a, b));
382 // And it observably equals a freshly-constructed `Default`.
383 assert_eq!(*a, PrincipalProfile::default());
384 }
385
386 #[test]
387 fn roundtrip_default() {
388 let p = PrincipalProfile::default();
389 let s = toml::to_string_pretty(&p).unwrap();
390 let back: PrincipalProfile = toml::from_str(&s).unwrap();
391 assert_eq!(p, back);
392 }
393
394 #[test]
395 fn roundtrip_populated() {
396 let p = PrincipalProfile {
397 profile_version: 1,
398 enabled: false,
399 groups: vec!["admins".into(), "ops_team".into()],
400 grants: vec!["capsule:install".into()],
401 revokes: vec!["system:shutdown".into()],
402 auth: AuthConfig {
403 methods: vec![AuthMethod::Keypair, AuthMethod::Passkey],
404 public_keys: vec!["ed25519:AAAA".into()],
405 },
406 network: NetworkConfig {
407 egress: vec!["api.example.com:443".into()],
408 },
409 process: ProcessConfig {
410 allow: vec!["/usr/bin/env".into()],
411 },
412 quotas: Quotas {
413 max_memory_bytes: 128 * 1024 * 1024,
414 max_timeout_secs: 600,
415 max_ipc_throughput_bytes: 5 * 1024 * 1024,
416 max_background_processes: 16,
417 max_storage_bytes: 2 * 1024 * 1024 * 1024,
418 max_cpu_fuel_per_sec: 4_000_000_000,
419 },
420 };
421 let s = toml::to_string_pretty(&p).unwrap();
422 let back: PrincipalProfile = toml::from_str(&s).unwrap();
423 assert_eq!(p, back);
424 }
425}