Skip to main content

nap_core/
permission_gate.rs

1//! Application-layer ACL for directory and file paths in a Lore workspace.
2//!
3//! ## Why app-layer and not VCS-layer
4//!
5//! Lore's stock server has no native path-level access control.  Instead,
6//! NAP enforces ACLs at the application layer by intercepting file read/
7//! write calls after they pass through the Lore VCS.  This keeps the
8//! loreserver simple and allows NAP to implement rich rules (prefix
9//! patterns, deny-overrides, role expansion) without server changes.
10//!
11//! ## Design
12//!
13//! [`PermissionGate`] is constructed with a set of [`Permission`] rules.
14//! Every request to read or write a path is checked against the ruleset:
15//!
16//! 1. If an explicit `Deny` matches the path, the request is **rejected**.
17//! 2. If a `Write` (or `Read`) matches, the request is **allowed**.
18//! 3. No match → **denied by default** (fail-closed).
19//!
20//! Rules are stored in `context/nap-gate.toml` inside the workspace and
21//! loaded on [`PermissionGate::load`].
22//!
23//! ## Future
24//!
25//! In a future iteration this may read from `lore file metadata` or from
26//! a dedicated ACL API, but for v0 the file-based approach gives us a
27//! portable, inspectable ACL that works without server changes.
28
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33
34use crate::error::NapError;
35use crate::vcs::{AccessLevel, Permission};
36
37// ---------------------------------------------------------------------------
38// Gate configuration (serialised to context/nap-gate.toml)
39// ---------------------------------------------------------------------------
40
41/// On-disk format for the permission gate config.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43struct GateConfig {
44    /// Default access level when no rules match.
45    #[serde(default = "default_deny")]
46    default: String,
47    /// Ordered list of access rules.
48    #[serde(default)]
49    rules: Vec<GateRule>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53struct GateRule {
54    /// Glob or prefix pattern (e.g. `agents/**` or `context/`).
55    path: String,
56    /// Principal (user or role) this rule applies to.
57    principal: String,
58    /// Access level: "read", "write", or "deny".
59    access: String,
60}
61
62fn default_deny() -> String {
63    "deny".to_string()
64}
65
66// ---------------------------------------------------------------------------
67// PermissionGate
68// ---------------------------------------------------------------------------
69
70/// Application-layer access-control gate for Lore workspace paths.
71///
72/// ## Usage
73///
74/// ```ignore
75/// let gate = PermissionGate::load(workspace_path).await?;
76/// gate.check_read("/context/secret.md", "alice")?;     // Err if denied
77/// gate.check_write("/entities/characters/bob.yaml", "alice")?; // Err if denied
78/// ```
79///
80/// ## Thread safety
81///
82/// `PermissionGate` is immutable after construction and safe to share
83/// across threads (immutable `&self` check methods).
84#[derive(Debug)]
85pub struct PermissionGate {
86    /// Workspace root path (resolves relative rules).
87    workspace_root: PathBuf,
88    /// Parsed rules keyed by (canonicalised prefix, principal).
89    /// The vec is searched in order; first match wins.
90    rules: Vec<(PathBuf, String, AccessLevel)>,
91    /// Cached access-level decisions for hot paths.
92    /// Stores the granted `AccessLevel` for a (path, principal) pair.
93    cache: std::sync::Mutex<HashMap<(PathBuf, String), AccessLevel>>,
94    /// Default access when no rule matches.
95    default: AccessLevel,
96}
97
98impl PermissionGate {
99    /// Load the permission gate from `context/nap-gate.toml` inside the
100    /// workspace.  If the file does not exist, returns a permissive gate
101    /// (default: write access for all principals).  Use
102    /// [`PermissionGate::strict`] to enforce a closed-by-default gate
103    /// when no config is present.
104    pub fn load(workspace_root: &Path) -> Result<Self, NapError> {
105        let config_path = workspace_root.join("context").join("nap-gate.toml");
106        if !config_path.exists() {
107            // No gate config → permissive (backwards-compatible).
108            return Ok(Self {
109                workspace_root: workspace_root.to_path_buf(),
110                rules: Vec::new(),
111                cache: std::sync::Mutex::new(HashMap::new()),
112                default: AccessLevel::Write,
113            });
114        }
115
116        let contents = std::fs::read_to_string(&config_path).map_err(|e| {
117            NapError::Other(format!(
118                "failed to read gate config at {:?}: {}",
119                config_path, e
120            ))
121        })?;
122
123        let config: GateConfig = toml::from_str(&contents).map_err(|e| {
124            NapError::Other(format!(
125                "failed to parse gate config at {:?}: {}",
126                config_path, e
127            ))
128        })?;
129
130        let default = match config.default.as_str() {
131            "read" => AccessLevel::Read,
132            "write" => AccessLevel::Write,
133            "deny" => AccessLevel::None,
134            other => {
135                return Err(NapError::Other(format!(
136                    "unknown default access level '{}' in gate config at {:?}",
137                    other, config_path
138                )));
139            }
140        };
141
142        let mut rules: Vec<(PathBuf, String, AccessLevel)> = Vec::new();
143        for rule in &config.rules {
144            let access = match rule.access.as_str() {
145                "read" => AccessLevel::Read,
146                "write" => AccessLevel::Write,
147                "deny" | "none" => AccessLevel::None,
148                other => {
149                    return Err(NapError::Other(format!(
150                        "unknown access level '{}' in rule for path '{}'",
151                        other, rule.path
152                    )));
153                }
154            };
155            rules.push((PathBuf::from(&rule.path), rule.principal.clone(), access));
156        }
157
158        Ok(Self {
159            workspace_root: workspace_root.to_path_buf(),
160            rules,
161            cache: std::sync::Mutex::new(HashMap::new()),
162            default,
163        })
164    }
165
166    /// Create a strict gate that denies everything by default, even
167    /// when no config file is present.  Useful for agents or untrusted
168    /// contexts.
169    pub fn strict(workspace_root: &Path) -> Self {
170        Self {
171            workspace_root: workspace_root.to_path_buf(),
172            rules: Vec::new(),
173            cache: std::sync::Mutex::new(HashMap::new()),
174            default: AccessLevel::None,
175        }
176    }
177
178    /// Create a fully permissive gate (any principal may read/write any
179    /// path).  Useful for local development or trusted automation.
180    pub fn permissive(workspace_root: &Path) -> Self {
181        Self {
182            workspace_root: workspace_root.to_path_buf(),
183            rules: Vec::new(),
184            cache: std::sync::Mutex::new(HashMap::new()),
185            default: AccessLevel::Write,
186        }
187    }
188
189    /// Build a gate from an explicit list of [`Permission`] entries.
190    pub fn from_permissions(
191        workspace_root: &Path,
192        permissions: &[Permission],
193        default: AccessLevel,
194    ) -> Self {
195        let rules: Vec<(PathBuf, String, AccessLevel)> = permissions
196            .iter()
197            .map(|p| (PathBuf::from(&p.path_prefix), p.principal.clone(), p.access))
198            .collect();
199
200        Self {
201            workspace_root: workspace_root.to_path_buf(),
202            rules,
203            cache: std::sync::Mutex::new(HashMap::new()),
204            default,
205        }
206    }
207
208    /// Check whether `principal` may read `path`.
209    pub fn check_read(&self, path: &str, principal: &str) -> Result<(), NapError> {
210        self.check(path, principal, AccessLevel::Read)
211    }
212
213    /// Check whether `principal` may write `path`.
214    pub fn check_write(&self, path: &str, principal: &str) -> Result<(), NapError> {
215        self.check(path, principal, AccessLevel::Write)
216    }
217
218    /// Core check: does the rule set grant `required` access for `principal`
219    /// on `path`?
220    fn check(&self, path: &str, principal: &str, required: AccessLevel) -> Result<(), NapError> {
221        let cache_key = (PathBuf::from(path), principal.to_string());
222        {
223            let cache = self.cache.lock().unwrap();
224            if let Some(&granted) = cache.get(&cache_key) {
225                // Cache hit: check if the cached access level satisfies
226                // the required level (Write implies Read).
227                if granted == AccessLevel::Write {
228                    return Ok(());
229                }
230                if granted == AccessLevel::Read && required == AccessLevel::Read {
231                    return Ok(());
232                }
233                return Err(NapError::PermissionDenied(format!(
234                    "access denied to '{}' for '{}' (cached)",
235                    path, principal
236                )));
237            }
238        }
239
240        let result = self.check_uncached(path, principal, required);
241        // Cache the granted access level (determined by re-checking with Read).
242        let granted = self
243            .check_uncached(path, principal, AccessLevel::Read)
244            .map(|_| {
245                // Check if Write is also granted.
246                if self
247                    .check_uncached(path, principal, AccessLevel::Write)
248                    .is_ok()
249                {
250                    AccessLevel::Write
251                } else {
252                    AccessLevel::Read
253                }
254            })
255            .unwrap_or(AccessLevel::None);
256
257        {
258            let mut cache = self.cache.lock().unwrap();
259            cache.insert(cache_key, granted);
260        }
261
262        result
263    }
264
265    fn check_uncached(
266        &self,
267        path: &str,
268        principal: &str,
269        required: AccessLevel,
270    ) -> Result<(), NapError> {
271        // Normalize paths: strip leading `/` so `Path::join` works
272        // correctly on all platforms (macOS treats `/public` as root).
273        let norm_path = path.strip_prefix('/').unwrap_or(path);
274        let norm_prefix = |p: &Path| -> PathBuf {
275            let s = p.to_string_lossy();
276            let relative = s.strip_prefix('/').unwrap_or(&s);
277            self.workspace_root.join(relative)
278        };
279
280        let request_path = self.workspace_root.join(norm_path);
281
282        // Collect all matching rules and pick the one with the longest
283        // (most specific) prefix.  This way a `Write` rule on
284        // `entities/characters` overrides a `Read` rule on `entities`.
285        let mut best_match: Option<(usize, &AccessLevel)> = None;
286        let mut best_prefix_len: usize = 0;
287
288        for (rule_prefix, rule_principal, rule_access) in &self.rules {
289            let prefix = norm_prefix(rule_prefix);
290
291            if !request_path.starts_with(&prefix) {
292                continue;
293            }
294
295            if rule_principal != "*" && rule_principal != principal {
296                continue;
297            }
298
299            let prefix_len = prefix.as_os_str().len();
300            if best_match.is_none() || prefix_len > best_prefix_len {
301                best_match = Some((prefix_len, rule_access));
302                best_prefix_len = prefix_len;
303            }
304        }
305
306        // ── Apply the best (most specific) matching rule ──────────────
307        if let Some((_, access)) = best_match {
308            match access {
309                AccessLevel::None => {
310                    return Err(NapError::PermissionDenied(format!(
311                        "principal '{}' is denied access to '{}'",
312                        principal, path
313                    )));
314                }
315                AccessLevel::Read => {
316                    if required == AccessLevel::Write {
317                        return Err(NapError::PermissionDenied(format!(
318                            "principal '{}' has read-only access to '{}'",
319                            principal, path
320                        )));
321                    }
322                    return Ok(());
323                }
324                AccessLevel::Write => {
325                    return Ok(());
326                }
327            }
328        }
329
330        // ── No rule matched → apply default ──────────────────────────
331        match self.default {
332            AccessLevel::None => Err(NapError::PermissionDenied(format!(
333                "principal '{}' is denied access to '{}' (default deny)",
334                principal, path
335            ))),
336            AccessLevel::Read => {
337                if required == AccessLevel::Write {
338                    return Err(NapError::PermissionDenied(format!(
339                        "principal '{}' has read-only access to '{}' (default read)",
340                        principal, path
341                    )));
342                }
343                Ok(())
344            }
345            AccessLevel::Write => Ok(()),
346        }
347    }
348}
349
350// ---------------------------------------------------------------------------
351// Tests
352// ---------------------------------------------------------------------------
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::fs;
358
359    #[test]
360    fn test_permissive_gate_allows_all() {
361        let dir = tempfile::TempDir::new().unwrap();
362        let gate = PermissionGate::permissive(dir.path());
363        assert!(gate.check_read("/any/path", "alice").is_ok());
364        assert!(gate.check_write("/any/path", "alice").is_ok());
365        assert!(gate.check_write("/any/path", "mallory").is_ok());
366    }
367
368    #[test]
369    fn test_strict_gate_denies_all() {
370        let dir = tempfile::TempDir::new().unwrap();
371        let gate = PermissionGate::strict(dir.path());
372        assert!(gate.check_read("/any/path", "alice").is_err());
373        assert!(gate.check_write("/any/path", "alice").is_err());
374    }
375
376    #[test]
377    fn test_from_permissions() {
378        let dir = tempfile::TempDir::new().unwrap();
379        let perms = vec![
380            Permission {
381                path_prefix: "/public".to_string(),
382                principal: "*".to_string(),
383                access: AccessLevel::Read,
384            },
385            Permission {
386                path_prefix: "/admin".to_string(),
387                principal: "alice".to_string(),
388                access: AccessLevel::Write,
389            },
390        ];
391        let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
392
393        // Public is readable by anyone.
394        assert!(gate.check_read("/public/readme.md", "bob").is_ok());
395        // But not writable.
396        assert!(gate.check_write("/public/readme.md", "bob").is_err());
397        // Admin is writeable by alice.
398        assert!(gate.check_write("/admin/secret.md", "alice").is_ok());
399        // Admin is denied for bob.
400        assert!(gate.check_write("/admin/secret.md", "bob").is_err());
401        // Default deny path.
402        assert!(gate.check_read("/other", "alice").is_err());
403    }
404
405    #[test]
406    fn test_load_from_file() {
407        let dir = tempfile::TempDir::new().unwrap();
408        let context_dir = dir.path().join("context");
409        fs::create_dir_all(&context_dir).unwrap();
410
411        let config = r#"
412default = "deny"
413
414[[rules]]
415path = "entities"
416principal = "*"
417access = "read"
418
419[[rules]]
420path = "entities/characters"
421principal = "alice"
422access = "write"
423"#;
424        fs::write(context_dir.join("nap-gate.toml"), config).unwrap();
425
426        let gate = PermissionGate::load(dir.path()).unwrap();
427
428        // Everyone can read entities.
429        assert!(gate.check_read("entities/foo.yaml", "bob").is_ok());
430        // But not write them unless they're alice.
431        assert!(gate.check_write("entities/foo.yaml", "bob").is_err());
432        assert!(gate.check_write("entities/foo.yaml", "alice").is_err()); // only characters/
433        assert!(
434            gate.check_write("entities/characters/hero.yaml", "alice")
435                .is_ok()
436        );
437        // Default deny for unconfigured paths.
438        assert!(gate.check_read("context/secret.md", "alice").is_err());
439    }
440
441    #[test]
442    fn test_cache_hits() {
443        let dir = tempfile::TempDir::new().unwrap();
444        let gate = PermissionGate::permissive(dir.path());
445
446        // First check populates the cache.
447        assert!(gate.check_read("/file", "alice").is_ok());
448        // Second check hits the cache (won't re-evaluate rules).
449        assert!(gate.check_read("/file", "alice").is_ok());
450    }
451
452    #[test]
453    fn test_cache_miss_different_principal() {
454        let dir = tempfile::TempDir::new().unwrap();
455        let perms = vec![Permission {
456            path_prefix: "/".to_string(),
457            principal: "alice".to_string(),
458            access: AccessLevel::Write,
459        }];
460        let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
461
462        assert!(gate.check_read("/file", "alice").is_ok());
463        // Different principal → different cache key → re-evaluated.
464        assert!(gate.check_read("/file", "bob").is_err());
465    }
466
467    #[test]
468    fn test_invalid_config_default() {
469        let dir = tempfile::TempDir::new().unwrap();
470        let context_dir = dir.path().join("context");
471        fs::create_dir_all(&context_dir).unwrap();
472        fs::write(
473            context_dir.join("nap-gate.toml"),
474            r#"default = "superadmin""#,
475        )
476        .unwrap();
477
478        let result = PermissionGate::load(dir.path());
479        assert!(result.is_err());
480        assert!(
481            result.unwrap_err().to_string().contains("unknown default"),
482            "expected 'unknown default' error"
483        );
484    }
485}