Skip to main content

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}