Skip to main content

boatramp_types/
authz.rs

1//! Control-plane **authorization** vocabulary and RBAC policy.
2//!
3//! This is the wasm-clean, pure core of authorization: the `action × resource`
4//! right vocabulary, the request → required-[`Right`] mapping (the analogue of
5//! the old `required_scope`), the RBAC [`AuthzPolicy`] (roles → right
6//! templates) with its built-in default, and the pure [`RightSet::allows`]
7//! decision. The COSE/Cedar engine (`boatramp_core::cose` + `::cedar`) reuses these types
8//! and mirrors [`RightSet::allows`]; keeping the semantics
9//! here means the server, CLI, and tests can't drift from the token format.
10//!
11//! No IO, no async, no authz-engine dependency — so it compiles to the edge target
12//! and is exhaustively unit-testable.
13
14use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17
18/// What a principal may *do* to a resource. [`Action::Admin`] is the superuser
19/// action: holding it on a resource satisfies any other action there.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Action {
23    /// Read/list (GET endpoints).
24    Read,
25    /// Mutate configuration (site config, aliases, domain verification, cache).
26    Write,
27    /// Ship content: create + activate deployments, upload blobs.
28    Deploy,
29    /// Full control of the resource (implies read/write/deploy).
30    Admin,
31}
32
33/// A class of control-plane resource a [`Right`] governs. Three are **target-scoped**:
34/// [`Resource::Site`] (target = `"<project>/<site>"`, the 0.2.0 project-qualified
35/// form), [`Resource::Project`] (target = `"<project>"`, governing the project's
36/// **own** resources — functions, compute, workflows, and the project entity itself),
37/// and [`Resource::Secrets`] (target = `"<project>"`, the project's sealed secret
38/// store). The rest are global.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Resource {
42    /// A single site (`target` = `"<project>/<site>"`): deployments, config, aliases, …
43    Site,
44    /// A project (`target` = `"<project>"`): its functions, compute, workflows, and
45    /// project-level config/CRUD. The owning + tenant boundary above a site.
46    Project,
47    /// A project's internal secret store (`target` = `"<project>"`): the sealed
48    /// `boatramp:<name>` values. Separate from [`Resource::Project`] so managing
49    /// credentials is a distinct, admin-gated right, auditable on its own.
50    Secrets,
51    /// Content-addressed blob uploads (`PUT /api/blobs/<hash>`).
52    Blobs,
53    /// API token management (`/api/tokens`).
54    Tokens,
55    /// TLS certificate status (`/api/certs`).
56    Certs,
57    /// Cache invalidation (`/api/cache/invalidate`).
58    Cache,
59    /// Node/system operations: metrics, prune, scrub, site listing.
60    System,
61}
62
63impl Resource {
64    /// Every resource variant — used to expand the `admin` role to "all rights".
65    pub const ALL: [Self; 8] = [
66        Self::Site,
67        Self::Project,
68        Self::Secrets,
69        Self::Blobs,
70        Self::Tokens,
71        Self::Certs,
72        Self::Cache,
73        Self::System,
74    ];
75
76    /// The serde term for this resource (matches `rename_all`).
77    pub fn as_str(self) -> &'static str {
78        match self {
79            Self::Site => "site",
80            Self::Project => "project",
81            Self::Secrets => "secrets",
82            Self::Blobs => "blobs",
83            Self::Tokens => "tokens",
84            Self::Certs => "certs",
85            Self::Cache => "cache",
86            Self::System => "system",
87        }
88    }
89}
90
91impl Action {
92    /// The serde term for this action (matches `rename_all`).
93    pub fn as_str(self) -> &'static str {
94        match self {
95            Self::Read => "read",
96            Self::Write => "write",
97            Self::Deploy => "deploy",
98            Self::Admin => "admin",
99        }
100    }
101}
102
103impl std::fmt::Display for Resource {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.write_str(self.as_str())
106    }
107}
108
109impl std::fmt::Display for Action {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.write_str(self.as_str())
112    }
113}
114
115/// A single grant or requirement: an `action` on a `resource`, optionally scoped
116/// to a `target` (a site name for [`Resource::Site`]). A `target` of `None` on a
117/// *granted* right is a wildcard ("all targets"); a required right for a site
118/// always carries `Some(site)`.
119#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120pub struct Right {
121    /// The resource class this right governs.
122    pub resource: Resource,
123    /// The site name for [`Resource::Site`]; `None` (wildcard/global) otherwise.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub target: Option<String>,
126    /// The permitted action.
127    pub action: Action,
128}
129
130impl Right {
131    /// Construct a right.
132    pub fn new(resource: Resource, target: Option<String>, action: Action) -> Self {
133        Self {
134            resource,
135            target,
136            action,
137        }
138    }
139
140    /// The target term: the site name, or `*` for a wildcard/global right.
141    pub fn target_term(&self) -> &str {
142        self.target.as_deref().unwrap_or("*")
143    }
144
145    /// Whether holding `self` (a *granted* right) satisfies a `required` right:
146    /// same resource, the granted action matches or is [`Action::Admin`], and the
147    /// granted target is a wildcard (`None`/`*`) or equals the required target.
148    pub fn satisfies(&self, required: &Self) -> bool {
149        self.resource == required.resource
150            && (self.action == required.action || self.action == Action::Admin)
151            && target_matches(self.target.as_deref(), required.target.as_deref())
152    }
153
154    /// Map an HTTP `method` + request `path` to the single right it requires, or
155    /// `None` for endpoints not gated by a right (the OIDC→token exchange).
156    ///
157    /// This is the authoritative request→right table. Unknown
158    /// `/api/sites/<s>/…` subpaths fall through to the most restrictive
159    /// `system · admin` so a narrow token can never reach an unmapped action.
160    pub fn required(method: &str, path: &str) -> Option<Self> {
161        let m = method.to_ascii_uppercase();
162        let get = m == "GET";
163
164        // Self-service endpoints gated only by holding *some* valid token, not a
165        // right: the OIDC→token exchange (carries an IdP JWT) and `whoami`
166        // (a principal reading its own identity). The handlers verify the token.
167        if path == "/api/auth/exchange" || path == "/api/auth/whoami" {
168            return None;
169        }
170
171        // Mesh join: the joiner presents a single-use *join
172        // token* (verified by the handler), not an admin bearer — so this exact
173        // path is unauthenticated at the RBAC layer. Note the `==`: the sibling
174        // `/api/cluster/join-token` (minting) stays admin-scoped via the default.
175        if path == "/api/cluster/join" {
176            return None;
177        }
178
179        // First-token bootstrap: the caller presents a single-use, operator-set
180        // *bootstrap secret* (verified by the handler), not an admin bearer — so
181        // this exact path is unauthenticated at the RBAC layer. Note the `==`: the
182        // sibling `/api/tokens` (minting) stays admin-scoped via the default below.
183        if path == "/api/tokens/bootstrap" {
184            return None;
185        }
186
187        // Content blobs are content-addressed (not site-specific); uploading is
188        // a deploy-grade action.
189        if path.starts_with("/api/blobs/") {
190            return Some(Self::new(Resource::Blobs, None, Action::Deploy));
191        }
192
193        // Attaching a host **without** an ownership proof (`domain add
194        // --unverified`) is an admin-only override: it asserts ownership of an
195        // arbitrary hostname, so a site-scoped publisher must never reach it
196        // (that would let them claim someone else's domain). Gate it at
197        // `system·admin` explicitly, above the per-site branch that would
198        // otherwise map it to the site-write right.
199        if m == "POST" && path.contains("/domains/") && path.ends_with("/attach-unverified") {
200            return Some(Self::new(Resource::System, None, Action::Admin));
201        }
202
203        // Persistent-**volume** management (`compute volume ls/rm`) is a NODE-GLOBAL
204        // operator tool: it lists/removes volume directories across *every*
205        // project/tenant on the node (keyed by bare volume name, not scoped to a
206        // project). It must therefore be gated at `system·admin` — never reachable by a
207        // project-scoped grant. Both the general `/api/compute/*` branch and the
208        // project-scoped `.../compute/*` catch-all below would otherwise map it to a
209        // per-project `Project·Read/Deploy` right, which a `project_admin`/`_publisher`
210        // (delete) or even a `project_viewer` (list) token on *any* project satisfies —
211        // letting that tenant enumerate and irreversibly destroy *another* tenant's
212        // volumes. Gate it explicitly, above both branches, matching both the direct
213        // (`/api/compute/volumes…`) and project-scoped (`/api/projects/<p>/compute/
214        // volumes…`) path forms (`require_auth` authorizes the original, project-
215        // qualified path, so both can appear here).
216        if is_compute_volumes_path(path) {
217            return Some(Self::new(Resource::System, None, Action::Admin));
218        }
219
220        // Compute **maintenance/diagnostic** tools (`compute status|ipam|dns|reconcile|
221        // restart|set-health|netdiag`) are NODE-GLOBAL operator instruments: they
222        // observe *and* override the reconcile plane across *every* tenant on the node
223        // (all replicas' health/state/IP, the shared IP pool, the internal-DNS fleet,
224        // and forcing/kicking replicas). Like `compute volumes`, they must be gated at
225        // `system·admin` — the general `/api/compute/*` branch and the project-scoped
226        // catch-all below would otherwise map them to a per-project `Project·Read/Deploy`
227        // right that any tenant's token satisfies, leaking cross-tenant state or letting
228        // one tenant flip another's health. Gate explicitly, above both branches,
229        // matching both the direct (`/api/compute/status…`) and project-scoped
230        // (`/api/projects/<p>/compute/status…`) forms.
231        if is_compute_maintenance_path(path) {
232            return Some(Self::new(Resource::System, None, Action::Admin));
233        }
234
235        // Project-scoped endpoints: `/api/projects/<proj>/<rest...>` (0.2.0). Parsed
236        // through the shared `project_api_path` so this and the request-scoping
237        // middleware agree on the tenant segment.
238        if let Some((proj, sub)) = project_api_path(path) {
239            if proj.is_empty() {
240                // `/api/projects/` (trailing slash) or a malformed `//…` — listing.
241                return Some(Self::new(Resource::System, None, Action::Read));
242            }
243            let sub: Vec<&str> = sub.split('/').filter(|s| !s.is_empty()).collect();
244            return Some(match sub.split_first() {
245                // The project entity itself: read, or manage (admin).
246                None => Self::new(
247                    Resource::Project,
248                    Some(proj.to_string()),
249                    if get { Action::Read } else { Action::Admin },
250                ),
251                // A site within the project: `.../sites/<site>/<site-sub...>`.
252                Some((&"sites", tail)) => {
253                    let site = tail.first().copied().unwrap_or("");
254                    if site.is_empty() {
255                        return Some(Self::new(
256                            Resource::Project,
257                            Some(proj.to_string()),
258                            Action::Read,
259                        ));
260                    }
261                    let site_sub: Vec<&str> = tail.iter().skip(1).copied().collect();
262                    match site_subpath_action(&m, get, &site_sub) {
263                        Some(a) => Self::new(Resource::Site, Some(format!("{proj}/{site}")), a),
264                        // Unknown subpath — deny-safe.
265                        None => Self::new(Resource::System, None, Action::Admin),
266                    }
267                }
268                // The project's internal secret store: sensitive (sealed credentials),
269                // so gated above the general project-owned mapping with its own
270                // `Resource::Secrets` — list with `Read`, mutate with `Write` (both
271                // satisfied by a `Secrets·Admin` grant); target = the project.
272                Some((&"secrets", _)) => Self::new(
273                    Resource::Secrets,
274                    Some(proj.to_string()),
275                    if get { Action::Read } else { Action::Write },
276                ),
277                // The project's SMTP email profiles are credential config (a sealed
278                // password + relay config), so — like `secrets` — they are gated with
279                // `Resource::Secrets` rather than the general project mapping: list/show
280                // with `Read`, set/delete with `Write` (both satisfied by a
281                // `Secrets·Admin` grant); target = the project.
282                Some((&"email", _)) => Self::new(
283                    Resource::Secrets,
284                    Some(proj.to_string()),
285                    if get { Action::Read } else { Action::Write },
286                ),
287                // Project-owned resources (functions/compute/workflows/config/…):
288                // read with `Project·Read`, mutate with `Project·Deploy`.
289                Some(_) => Self::new(
290                    Resource::Project,
291                    Some(proj.to_string()),
292                    if get { Action::Read } else { Action::Deploy },
293                ),
294            });
295        }
296
297        // Legacy per-site endpoints: `/api/sites/<site>/<sub...>`. The site lives in
298        // the `default` project post-migration, so the target is project-qualified.
299        if let Some(rest) = path.strip_prefix("/api/sites/") {
300            let mut segs = rest.split('/');
301            let site = segs.next().unwrap_or("");
302            if site.is_empty() {
303                // `/api/sites/` (trailing slash) — listing.
304                return Some(Self::new(Resource::System, None, Action::Read));
305            }
306            let target = Some(format!("{}/{site}", crate::project::DEFAULT_PROJECT));
307            let sub: Vec<&str> = segs.filter(|s| !s.is_empty()).collect();
308            let action = site_subpath_action(&m, get, &sub);
309            return Some(match action {
310                Some(a) => Self::new(Resource::Site, target, a),
311                // Unknown subpath — deny-safe.
312                None => Self::new(Resource::System, None, Action::Admin),
313            });
314        }
315
316        // Exact, non-site endpoints.
317        let default_project = crate::project::DEFAULT_PROJECT.to_string();
318        let right = match path {
319            "/api/sites" => Self::new(Resource::System, None, Action::Read),
320            // Listing projects is a node-level read; creating one is a node-admin act
321            // (only `/api/projects` exactly — a specific project is handled above).
322            "/api/projects" => {
323                let action = if get { Action::Read } else { Action::Admin };
324                Self::new(Resource::System, None, action)
325            }
326            // Functions (FA-1/FA-2) are **project-owned** (0.2.0): read the view with
327            // `project·read`, mutate (deploy a version, alias, rollback, delete) with
328            // `project·deploy`, scoped to the default project for the legacy path.
329            p if p == "/api/functions" || p.starts_with("/api/functions/") => {
330                let action = if get { Action::Read } else { Action::Deploy };
331                Self::new(Resource::Project, Some(default_project.clone()), action)
332            }
333            // Workflows (FA-6) are project-owned too; same shape as `/api/functions`.
334            p if p == "/api/workflows" || p.starts_with("/api/workflows/") => {
335                let action = if get { Action::Read } else { Action::Deploy };
336                Self::new(Resource::Project, Some(default_project.clone()), action)
337            }
338            // Compute workloads (project-owned): read/deploy within the default project.
339            p if p == "/api/compute" || p.starts_with("/api/compute/") => {
340                let action = if get { Action::Read } else { Action::Deploy };
341                Self::new(Resource::Project, Some(default_project.clone()), action)
342            }
343            // Operator SQL to a managed database (project-owned): migrations + queries
344            // are operator tools scoped to the default project. `project·deploy` (they
345            // are POST bodies that mutate or read the project's managed DB); the
346            // `compute exec`-style risk on writes is additionally posture-gated.
347            p if p.starts_with("/api/sql/") => Self::new(
348                Resource::Project,
349                Some(default_project.clone()),
350                Action::Deploy,
351            ),
352            // GraphQL administration — subgraph registration, the operation safelist,
353            // and the composed supergraph — is project-owned (0.2.0), the same as
354            // functions/compute/workflows: read the surface with `project·read`, mutate
355            // it with `project·deploy`, scoped to the default project for this global
356            // path (the project-scoped `/api/projects/<proj>/graphql/…` form is handled
357            // above).
358            p if p == "/api/graphql" || p.starts_with("/api/graphql/") => {
359                let action = if get { Action::Read } else { Action::Deploy };
360                Self::new(Resource::Project, Some(default_project.clone()), action)
361            }
362            "/api/blobs" => Self::new(Resource::Blobs, None, Action::Deploy),
363            "/api/certs" => Self::new(Resource::Certs, None, Action::Read),
364            "/api/cache/invalidate" => Self::new(Resource::Cache, None, Action::Write),
365            "/api/metrics" => Self::new(Resource::System, None, Action::Read),
366            "/api/prune" | "/api/scrub" => Self::new(Resource::System, None, Action::Admin),
367            p if p == "/api/tokens" || p.starts_with("/api/tokens/") => {
368                Self::new(Resource::Tokens, None, Action::Admin)
369            }
370            p if p == "/api/authz/policy" || p.starts_with("/api/authz/") => {
371                Self::new(Resource::System, None, Action::Admin)
372            }
373            // Any other `/api/*` path: deny-safe (must hold system·admin).
374            _ => Self::new(Resource::System, None, Action::Admin),
375        };
376        Some(right)
377    }
378}
379
380/// The action a per-site subpath requires, or `None` if the subpath is unknown.
381fn site_subpath_action(method: &str, get: bool, sub: &[&str]) -> Option<Action> {
382    match sub.first().copied() {
383        // `deployments`, `deployments/<id>`, `deployments/<id>/activate`.
384        Some("deployments") => {
385            let activate = sub.last() == Some(&"activate");
386            if activate || method == "POST" {
387                Some(Action::Deploy) // activate, or create a deployment
388            } else if get {
389                Some(Action::Read)
390            } else {
391                None
392            }
393        }
394        Some("current") if get => Some(Action::Read),
395        Some("config") => {
396            if get {
397                Some(Action::Read)
398            } else if method == "PUT" {
399                Some(Action::Write)
400            } else {
401                None
402            }
403        }
404        // `domains/<host>/verification[/check]`, `domain-verifications`.
405        Some("domains") => {
406            let check = sub.last() == Some(&"check"); // a status check (POST, but read-grade)
407            if get || check {
408                Some(Action::Read)
409            } else if method == "POST" || method == "DELETE" {
410                Some(Action::Write)
411            } else {
412                None
413            }
414        }
415        Some("domain-verifications") if get => Some(Action::Read),
416        Some("aliases") => {
417            if get {
418                Some(Action::Read)
419            } else if method == "PUT" || method == "DELETE" {
420                Some(Action::Write)
421            } else {
422                None
423            }
424        }
425        // `_boatramp/handlers`, `_boatramp/logs` (per-site observability, read);
426        // `_boatramp/dlq` purge/redrive is a destructive site-scoped write.
427        Some("_boatramp") => {
428            if get {
429                Some(Action::Read)
430            } else if method == "POST" && sub.get(1) == Some(&"dlq") {
431                Some(Action::Write)
432            } else {
433                None
434            }
435        }
436        _ => None,
437    }
438}
439
440/// The project segment of a `"<project>/<site>"` target (the part before the first
441/// `/`), or the whole string when it carries no `/` (a bare project target).
442pub fn project_of(target: &str) -> &str {
443    target.split_once('/').map_or(target, |(p, _)| p)
444}
445
446/// Split an `/api/projects/<proj>/<sub…>` request path into its tenant project
447/// segment and the remaining sub-path, or `None` when the path is not
448/// project-scoped. `proj` is the first path segment after the prefix (possibly
449/// empty for a malformed `//…` or a trailing-slash `/api/projects/`); `sub` is
450/// everything after the first `/` (empty for the bare `/api/projects/<proj>` entity
451/// path).
452///
453/// Both the request-scoping middleware (`project_scope::scope_of`) and
454/// [`Right::required`] resolve the tenant through this one function, so the two can
455/// never disagree on which project a request targets — a confused-deputy hazard if
456/// they parsed it differently (each still applies its own policy to an empty
457/// segment: the middleware carries the default tenant, `Right::required` treats it
458/// as the listing/System right; both fail closed).
459pub fn project_api_path(path: &str) -> Option<(&str, &str)> {
460    let rest = path.strip_prefix("/api/projects/")?;
461    Some(rest.split_once('/').unwrap_or((rest, "")))
462}
463
464/// Whether `path` targets the node-global persistent-**volume** management surface
465/// (`compute volume ls/rm`) — `GET`/`DELETE` on `.../compute/volumes[/<name>]`, in
466/// either the direct (`/api/compute/volumes…`) or the project-scoped
467/// (`/api/projects/<proj>/compute/volumes…`) form. These endpoints operate across
468/// every tenant on the node, so [`Right::required`] gates them at `system·admin`
469/// rather than the per-project right the surrounding `/api/compute/*` mappings give.
470fn is_compute_volumes_path(path: &str) -> bool {
471    // The `compute/` segment after either `/api/` or `/api/projects/<proj>/`.
472    let after_compute = path.strip_prefix("/api/compute/").or_else(|| {
473        project_api_path(path)
474            .and_then(|(proj, sub)| (!proj.is_empty()).then_some(sub)?.strip_prefix("compute/"))
475    });
476    matches!(after_compute, Some(rest) if rest == "volumes" || rest.starts_with("volumes/"))
477}
478
479/// Whether `path` targets the node-global compute **maintenance/diagnostic** surface
480/// (`compute status|ipam|dns|reconcile` and the `compute/maintenance/*` mutators —
481/// `restart`, `set-health`, `netdiag`), in either the direct (`/api/compute/status…`)
482/// or the project-scoped (`/api/projects/<proj>/compute/status…`) form. These
483/// instruments read and override the reconcile plane across every tenant on the node,
484/// so [`Right::required`] gates them at `system·admin` rather than the per-project
485/// right the surrounding `/api/compute/*` mappings give. The reserved first segments
486/// (`status`, `ipam`, `dns`, `reconcile`, `maintenance`) therefore shadow any workload
487/// of the same name on these routes — the same trade-off `volumes` already makes.
488fn is_compute_maintenance_path(path: &str) -> bool {
489    let after_compute = path.strip_prefix("/api/compute/").or_else(|| {
490        project_api_path(path)
491            .and_then(|(proj, sub)| (!proj.is_empty()).then_some(sub)?.strip_prefix("compute/"))
492    });
493    matches!(
494        after_compute,
495        Some(rest) if rest == "status"
496            || rest == "ipam"
497            || rest == "dns"
498            || rest.starts_with("dns/")
499            || rest == "reconcile"
500            || rest == "maintenance"
501            || rest.starts_with("maintenance/")
502    )
503}
504
505/// Whether a granted target covers a required target:
506/// - `None` — the global wildcard (an untargeted grant), covers everything;
507/// - `"<project>/*"` — a project wildcard, covers any `"<project>/<site>"` (the
508///   required target's project segment must equal `<project>`);
509/// - anything else — an exact string match.
510///
511/// A grant target of the literal string `"*"` is **not** a global wildcard: the
512/// wildcard is the *absence* of a target (`None`), so a `"*"` target matches only
513/// a resource literally named `*`. This keeps the pure oracle faithful to the
514/// Cedar authorizer, which likewise matches `"*"` as a literal set member — and a
515/// resource named `*` cannot be created (`validate_resource_name` rejects it).
516fn target_matches(granted: Option<&str>, required: Option<&str>) -> bool {
517    match granted {
518        None => true,
519        Some(g) => match g.strip_suffix("/*") {
520            Some(project) => required.is_some_and(|r| project_of(r) == project),
521            None => required == Some(g),
522        },
523    }
524}
525
526/// A set of granted [`Right`]s with the pure authorization decision. This is the
527/// pure-Rust reference decision: the differential oracle the Cedar authorizer is
528/// tested against, and used by issuance code that needs to reason about a role's
529/// effective rights.
530#[derive(Debug, Clone, Default, PartialEq, Eq)]
531pub struct RightSet {
532    rights: Vec<Right>,
533}
534
535impl RightSet {
536    /// An empty set (grants nothing).
537    pub fn new() -> Self {
538        Self::default()
539    }
540
541    /// Add a right (de-duplicated).
542    pub fn insert(&mut self, right: Right) {
543        if !self.rights.contains(&right) {
544            self.rights.push(right);
545        }
546    }
547
548    /// Whether any held right satisfies `required`.
549    pub fn allows(&self, required: &Right) -> bool {
550        self.rights.iter().any(|g| g.satisfies(required))
551    }
552
553    /// Whether the set grants nothing.
554    pub fn is_empty(&self) -> bool {
555        self.rights.is_empty()
556    }
557
558    /// The held rights.
559    pub fn rights(&self) -> &[Right] {
560        &self.rights
561    }
562}
563
564impl FromIterator<Right> for RightSet {
565    fn from_iter<I: IntoIterator<Item = Right>>(iter: I) -> Self {
566        let mut set = Self::new();
567        for r in iter {
568            set.insert(r);
569        }
570        set
571    }
572}
573
574/// What a target-scoped role's grant target names.
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub enum TargetKind {
577    /// A per-site role: target is `"<project>/<site>"`.
578    Site,
579    /// A per-project role: target is a bare `"<project>"`.
580    Project,
581}
582
583/// A role granted to a principal: a role `name` from the [`AuthzPolicy`], plus an
584/// optional `target` for target-scoped roles — a `"<project>/<site>"` for a site
585/// role, or a bare `"<project>"` for a project role.
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587pub struct GrantedRole {
588    /// The role name (a key in [`AuthzPolicy::roles`]).
589    pub name: String,
590    /// The site this instance is scoped to, for target-scoped roles.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub target: Option<String>,
593}
594
595impl GrantedRole {
596    /// A global role (no target).
597    pub fn global(name: impl Into<String>) -> Self {
598        Self {
599            name: name.into(),
600            target: None,
601        }
602    }
603
604    /// A target-scoped role (e.g. `publisher` on a site).
605    pub fn scoped(name: impl Into<String>, target: impl Into<String>) -> Self {
606        Self {
607            name: name.into(),
608            target: Some(target.into()),
609        }
610    }
611
612    /// Parse a role spec: `"<role>"` (global) or `"<role>:<target>"`
613    /// (target-scoped). Used by the CLI `--role`, the API token-create body, and
614    /// the OIDC claim→roles mapping, so they agree on the format.
615    pub fn parse(spec: &str) -> Self {
616        match spec.split_once(':') {
617            Some((name, target)) if !target.trim().is_empty() => {
618                Self::scoped(name.trim(), target.trim())
619            }
620            _ => Self::global(spec.trim()),
621        }
622    }
623}
624
625/// How a [`RightTemplate`] derives its target when expanding a role.
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(rename_all = "snake_case")]
628pub enum TargetScope {
629    /// Wildcard/global: the expanded right has `target = None`.
630    AnyTarget,
631    /// Bind to the granted role instance's target verbatim (a site role's
632    /// `"<project>/<site>"`, or a project role's `"<project>"`).
633    RoleTarget,
634    /// A **project role** granting a per-site right over *every* site in its project:
635    /// the granted target is a bare project name `"<project>"` and the expanded right
636    /// gets the project-wildcard target `"<project>/*"`, which
637    /// [`target_matches`](Right::satisfies) treats as covering any `"<project>/<site>"`.
638    ProjectWildcard,
639}
640
641impl TargetScope {
642    /// Whether this scope binds a target (so its role is target-scoped).
643    pub fn is_targeted(self) -> bool {
644        matches!(self, Self::RoleTarget | Self::ProjectWildcard)
645    }
646}
647
648/// One right a role grants, before binding to a concrete target.
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650pub struct RightTemplate {
651    /// The resource the right governs.
652    pub resource: Resource,
653    /// The action granted.
654    pub action: Action,
655    /// How the target is derived when the role is expanded.
656    pub scope: TargetScope,
657}
658
659impl RightTemplate {
660    /// A global right template (`AnyTarget`).
661    pub fn any(resource: Resource, action: Action) -> Self {
662        Self {
663            resource,
664            action,
665            scope: TargetScope::AnyTarget,
666        }
667    }
668
669    /// A target-scoped right template (`RoleTarget`).
670    pub fn scoped(resource: Resource, action: Action) -> Self {
671        Self {
672            resource,
673            action,
674            scope: TargetScope::RoleTarget,
675        }
676    }
677
678    /// A project-wildcard right template (`ProjectWildcard`): a per-site right a
679    /// project role confers over every site in the project.
680    pub fn project_wildcard(resource: Resource, action: Action) -> Self {
681        Self {
682            resource,
683            action,
684            scope: TargetScope::ProjectWildcard,
685        }
686    }
687}
688
689/// The RBAC policy: roles → the rights they grant. Stored at KV `authz/policy`
690/// (schema v1); when absent the server uses [`AuthzPolicy::default_policy`].
691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[serde(deny_unknown_fields)]
693pub struct AuthzPolicy {
694    /// Pinned schema discriminant (`v1`).
695    #[serde(default = "crate::schema_version")]
696    pub version: u32,
697    /// Role name → the right templates it grants.
698    pub roles: BTreeMap<String, Vec<RightTemplate>>,
699}
700
701impl Default for AuthzPolicy {
702    fn default() -> Self {
703        Self::default_policy()
704    }
705}
706
707impl AuthzPolicy {
708    /// The built-in default policy: `admin`, `publisher`,
709    /// `deployer`, `viewer`, `operator`.
710    pub fn default_policy() -> Self {
711        let mut roles: BTreeMap<String, Vec<RightTemplate>> = BTreeMap::new();
712
713        // admin — every (resource, action). Expanded as one Admin right per
714        // resource with a wildcard target.
715        roles.insert(
716            "admin".to_string(),
717            Resource::ALL
718                .iter()
719                .map(|&r| RightTemplate::any(r, Action::Admin))
720                .collect(),
721        );
722
723        // publisher (site) — full control of its site + blob uploads.
724        roles.insert(
725            "publisher".to_string(),
726            vec![
727                RightTemplate::scoped(Resource::Site, Action::Read),
728                RightTemplate::scoped(Resource::Site, Action::Write),
729                RightTemplate::scoped(Resource::Site, Action::Deploy),
730                RightTemplate::any(Resource::Blobs, Action::Deploy),
731            ],
732        );
733
734        // deployer (site) — ship + read, but not edit config.
735        roles.insert(
736            "deployer".to_string(),
737            vec![
738                RightTemplate::scoped(Resource::Site, Action::Read),
739                RightTemplate::scoped(Resource::Site, Action::Deploy),
740                RightTemplate::any(Resource::Blobs, Action::Deploy),
741            ],
742        );
743
744        // viewer (site) — read-only on its site.
745        roles.insert(
746            "viewer".to_string(),
747            vec![RightTemplate::scoped(Resource::Site, Action::Read)],
748        );
749
750        // operator — node-level read + cache control, no site access.
751        roles.insert(
752            "operator".to_string(),
753            vec![
754                RightTemplate::any(Resource::System, Action::Read),
755                RightTemplate::any(Resource::Certs, Action::Read),
756                RightTemplate::any(Resource::Cache, Action::Write),
757            ],
758        );
759
760        // project-admin (project) — full control of the project: its own resources
761        // (functions/compute/workflows/config, via `Project·Admin`) AND every site in
762        // it (`Site·Admin` over the project wildcard) + blob uploads.
763        roles.insert(
764            "project_admin".to_string(),
765            vec![
766                RightTemplate::scoped(Resource::Project, Action::Admin),
767                // Managing the project's sealed secrets is an admin-level right
768                // (reading/rotating credentials), granted to the project admin only —
769                // not to publishers, who merely *reference* a `boatramp:<name>`.
770                RightTemplate::scoped(Resource::Secrets, Action::Admin),
771                RightTemplate::project_wildcard(Resource::Site, Action::Admin),
772                RightTemplate::any(Resource::Blobs, Action::Deploy),
773            ],
774        );
775
776        // project-publisher (project) — ship + configure any site in the project and
777        // manage its functions/compute (write + deploy + read), but not admin the
778        // project entity (no membership/role changes).
779        roles.insert(
780            "project_publisher".to_string(),
781            vec![
782                RightTemplate::scoped(Resource::Project, Action::Read),
783                RightTemplate::scoped(Resource::Project, Action::Write),
784                RightTemplate::scoped(Resource::Project, Action::Deploy),
785                RightTemplate::project_wildcard(Resource::Site, Action::Read),
786                RightTemplate::project_wildcard(Resource::Site, Action::Write),
787                RightTemplate::project_wildcard(Resource::Site, Action::Deploy),
788                RightTemplate::any(Resource::Blobs, Action::Deploy),
789            ],
790        );
791
792        // project-viewer (project) — read-only across the whole project.
793        roles.insert(
794            "project_viewer".to_string(),
795            vec![
796                RightTemplate::scoped(Resource::Project, Action::Read),
797                RightTemplate::project_wildcard(Resource::Site, Action::Read),
798            ],
799        );
800
801        Self {
802            version: crate::SCHEMA_VERSION,
803            roles,
804        }
805    }
806
807    /// Whether `role` is target-scoped (any of its templates binds the target).
808    pub fn role_takes_target(&self, role: &str) -> bool {
809        self.roles
810            .get(role)
811            .is_some_and(|ts| ts.iter().any(|t| t.scope.is_targeted()))
812    }
813
814    /// What kind of target a role's grant carries: a per-site `"<project>/<site>"`
815    /// ([`TargetKind::Site`], a role with a Site `RoleTarget` template) or a bare
816    /// `"<project>"` ([`TargetKind::Project`], a role with a `ProjectWildcard` or a
817    /// non-Site `RoleTarget` template). `None` for a global role. Used to normalize a
818    /// legacy site-only grant to the `default` project ([`normalize_grants`]).
819    ///
820    /// [`normalize_grants`]: Self::normalize_grants
821    pub fn role_target_kind(&self, role: &str) -> Option<TargetKind> {
822        let templates = self.roles.get(role)?;
823        let mut site = false;
824        let mut project = false;
825        for t in templates {
826            match t.scope {
827                TargetScope::RoleTarget if t.resource == Resource::Site => site = true,
828                TargetScope::RoleTarget => project = true,
829                TargetScope::ProjectWildcard => project = true,
830                TargetScope::AnyTarget => {}
831            }
832        }
833        // A Site `RoleTarget` role is per-site; a project role (ProjectWildcard, or a
834        // `RoleTarget` on the Project resource) is per-project.
835        if site {
836            Some(TargetKind::Site)
837        } else if project {
838            Some(TargetKind::Project)
839        } else {
840            None
841        }
842    }
843
844    /// Normalize legacy grants for 0.2.0: a **site** role granted a bare target with
845    /// no project segment (a pre-project token, e.g. `publisher:blog`) is read as the
846    /// `default` project (`publisher:default/blog`). Project and global roles, and any
847    /// already-qualified `"<project>/<site>"` target, pass through unchanged. Run once
848    /// at token→roles ingestion, before either [`rights_for`](Self::rights_for) or the
849    /// Cedar authorizer, so both decide on the same normalized grants.
850    pub fn normalize_grants(&self, roles: &[GrantedRole]) -> Vec<GrantedRole> {
851        roles
852            .iter()
853            .map(|g| match (&g.target, self.role_target_kind(&g.name)) {
854                (Some(t), Some(TargetKind::Site)) if !t.contains('/') => {
855                    GrantedRole::scoped(&g.name, format!("{}/{t}", crate::project::DEFAULT_PROJECT))
856                }
857                _ => g.clone(),
858            })
859            .collect()
860    }
861
862    /// Expand a principal's granted roles into the concrete [`RightSet`] they
863    /// confer under this policy. A target-scoped template on a role granted
864    /// without a target contributes nothing (defensive). This is the pure RBAC
865    /// expansion the Cedar authorizer reproduces as a policy set. Callers pass
866    /// grants already normalized by [`normalize_grants`](Self::normalize_grants).
867    pub fn rights_for(&self, roles: &[GrantedRole]) -> RightSet {
868        let mut set = RightSet::new();
869        for granted in roles {
870            let Some(templates) = self.roles.get(&granted.name) else {
871                continue;
872            };
873            for t in templates {
874                let target = match t.scope {
875                    TargetScope::AnyTarget => None,
876                    TargetScope::RoleTarget => match &granted.target {
877                        Some(x) => Some(x.clone()),
878                        None => continue,
879                    },
880                    // A project role's per-site right covers every site in its
881                    // project: expand the bare project target to `"<project>/*"`.
882                    TargetScope::ProjectWildcard => match &granted.target {
883                        Some(x) => Some(format!("{x}/*")),
884                        None => continue,
885                    },
886                };
887                set.insert(Right::new(t.resource, target, t.action));
888            }
889        }
890        set
891    }
892}
893
894/// KV key for the RBAC policy document (`authz/policy`); absent ⇒ the built-in
895/// [`AuthzPolicy::default_policy`].
896pub const POLICY_KEY: &str = "authz/policy";
897
898/// KV key prefix for revocation markers — presence of `authz/revoked/<id>`
899/// means the token with authority revocation id `<id>` (and its attenuations)
900/// is revoked.
901pub const REVOKED_PREFIX: &str = "authz/revoked/";
902
903/// KV key prefix for issued-token metadata (`authz/tokens/<id>`). The token
904/// itself is never stored — only this metadata, for `token ls`.
905pub const TOKEN_META_PREFIX: &str = "authz/tokens/";
906
907/// The revocation-marker key for an authority revocation id.
908pub fn revoked_key(revocation_id: &str) -> String {
909    format!("{REVOKED_PREFIX}{revocation_id}")
910}
911
912/// KV key prefix for extra trusted **root anchors** added by `auth rotate-root`
913/// (`auth/root/{alg:hex}`). Each is a `TokenPublicKey` trusted alongside the
914/// configured primary root during a make-before-break root rotation.
915pub const ROOT_ANCHOR_PREFIX: &str = "auth/root/";
916
917/// The root-anchor key trusting `pubkey` (an `alg:hex`-encoded `TokenPublicKey`).
918pub fn root_anchor_key(pubkey: &str) -> String {
919    format!("{ROOT_ANCHOR_PREFIX}{pubkey}")
920}
921
922/// The metadata key for an issued token (keyed by its authority revocation id).
923pub fn token_meta_key(id: &str) -> String {
924    format!("{TOKEN_META_PREFIX}{id}")
925}
926
927/// The single-use marker key for a redeemed first-token bootstrap secret
928/// (keyed by the secret's SHA-256 hex).
929pub fn bootstrap_key(secret_hash: &str) -> String {
930    format!("authz/bootstrap/{secret_hash}")
931}
932
933/// Metadata for an issued token (`authz/tokens/<id>`). The token itself is
934/// shown once at creation and never stored; this is what `token ls` reports and
935/// what `token rm` needs to find the revocation id. Schema v1.
936#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
937pub struct TokenMeta {
938    /// Pinned schema discriminant (`v1`).
939    #[serde(default = "crate::schema_version")]
940    pub version: u32,
941    /// Human label for the token.
942    pub label: String,
943    /// The roles the token grants.
944    pub roles: Vec<GrantedRole>,
945    /// Unix timestamp (seconds) of creation.
946    pub created_at: u64,
947    /// Unix timestamp (seconds) of expiry, if the token carries a TTL.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub expires_at: Option<u64>,
950    /// The authority revocation id (hex) — also the `authz/tokens/<id>` key and
951    /// the argument to `token rm`.
952    pub revocation_id: String,
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    #[test]
960    fn admin_satisfies_every_action_on_its_resource() {
961        let admin_site = Right::new(Resource::Site, None, Action::Admin);
962        for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
963            let required = Right::new(Resource::Site, Some("blog".into()), action);
964            assert!(
965                admin_site.satisfies(&required),
966                "admin must satisfy {action:?}"
967            );
968        }
969        // …but not a different resource.
970        assert!(!admin_site.satisfies(&Right::new(Resource::Tokens, None, Action::Read)));
971    }
972
973    #[test]
974    fn target_scoping_is_exact_unless_wildcard() {
975        let blog = Right::new(Resource::Site, Some("blog".into()), Action::Write);
976        assert!(blog.satisfies(&Right::new(
977            Resource::Site,
978            Some("blog".into()),
979            Action::Write
980        )));
981        assert!(!blog.satisfies(&Right::new(
982            Resource::Site,
983            Some("api".into()),
984            Action::Write
985        )));
986        // A wildcard grant covers any site.
987        let any = Right::new(Resource::Site, None, Action::Write);
988        assert!(any.satisfies(&Right::new(
989            Resource::Site,
990            Some("api".into()),
991            Action::Write
992        )));
993    }
994
995    #[test]
996    fn distinct_actions_do_not_imply_each_other() {
997        let write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
998        let deploy_req = Right::new(Resource::Site, Some("blog".into()), Action::Deploy);
999        assert!(
1000            !write.satisfies(&deploy_req),
1001            "write must not imply deploy (only admin does)"
1002        );
1003    }
1004
1005    /// Every row of the request→right table.
1006    #[test]
1007    fn required_right_table() {
1008        let cases: &[(&str, &str, Option<Right>)] = &[
1009            ("POST", "/api/auth/exchange", None),
1010            ("GET", "/api/auth/whoami", None),
1011            // Minting a mesh join token is admin-scoped (deny-safe default for
1012            // `/api/cluster/*`) — an operator issues it.
1013            (
1014                "POST",
1015                "/api/cluster/join-token",
1016                Some(Right::new(Resource::System, None, Action::Admin)),
1017            ),
1018            // Presenting a join token to join is gated by the token itself, not
1019            // an admin bearer (the handler verifies it) — exact-path `None`.
1020            ("POST", "/api/cluster/join", None),
1021            // Rotating this node's mesh key is an operator action → admin-scoped.
1022            (
1023                "POST",
1024                "/api/cluster/rotate-key",
1025                Some(Right::new(Resource::System, None, Action::Admin)),
1026            ),
1027            // Revoking a node from the mesh is an operator action → admin-scoped.
1028            (
1029                "POST",
1030                "/api/cluster/revoke",
1031                Some(Right::new(Resource::System, None, Action::Admin)),
1032            ),
1033            (
1034                "PUT",
1035                "/api/blobs/abc123",
1036                Some(Right::new(Resource::Blobs, None, Action::Deploy)),
1037            ),
1038            (
1039                "GET",
1040                "/api/sites",
1041                Some(Right::new(Resource::System, None, Action::Read)),
1042            ),
1043            (
1044                "POST",
1045                "/api/sites/blog/deployments",
1046                Some(Right::new(
1047                    Resource::Site,
1048                    Some("default/blog".into()),
1049                    Action::Deploy,
1050                )),
1051            ),
1052            (
1053                "GET",
1054                "/api/sites/blog/deployments",
1055                Some(Right::new(
1056                    Resource::Site,
1057                    Some("default/blog".into()),
1058                    Action::Read,
1059                )),
1060            ),
1061            (
1062                "GET",
1063                "/api/sites/blog/deployments/d1",
1064                Some(Right::new(
1065                    Resource::Site,
1066                    Some("default/blog".into()),
1067                    Action::Read,
1068                )),
1069            ),
1070            (
1071                "POST",
1072                "/api/sites/blog/deployments/d1/activate",
1073                Some(Right::new(
1074                    Resource::Site,
1075                    Some("default/blog".into()),
1076                    Action::Deploy,
1077                )),
1078            ),
1079            (
1080                "GET",
1081                "/api/sites/blog/current",
1082                Some(Right::new(
1083                    Resource::Site,
1084                    Some("default/blog".into()),
1085                    Action::Read,
1086                )),
1087            ),
1088            (
1089                "GET",
1090                "/api/sites/blog/config",
1091                Some(Right::new(
1092                    Resource::Site,
1093                    Some("default/blog".into()),
1094                    Action::Read,
1095                )),
1096            ),
1097            (
1098                "PUT",
1099                "/api/sites/blog/config",
1100                Some(Right::new(
1101                    Resource::Site,
1102                    Some("default/blog".into()),
1103                    Action::Write,
1104                )),
1105            ),
1106            (
1107                "GET",
1108                "/api/sites/blog/domains/x.example.com/verification",
1109                Some(Right::new(
1110                    Resource::Site,
1111                    Some("default/blog".into()),
1112                    Action::Read,
1113                )),
1114            ),
1115            (
1116                "POST",
1117                "/api/sites/blog/domains/x.example.com/verification",
1118                Some(Right::new(
1119                    Resource::Site,
1120                    Some("default/blog".into()),
1121                    Action::Write,
1122                )),
1123            ),
1124            (
1125                "DELETE",
1126                "/api/sites/blog/domains/x.example.com/verification",
1127                Some(Right::new(
1128                    Resource::Site,
1129                    Some("default/blog".into()),
1130                    Action::Write,
1131                )),
1132            ),
1133            (
1134                "POST",
1135                "/api/sites/blog/domains/x.example.com/verification/check",
1136                Some(Right::new(
1137                    Resource::Site,
1138                    Some("default/blog".into()),
1139                    Action::Read,
1140                )),
1141            ),
1142            (
1143                "GET",
1144                "/api/sites/blog/domain-verifications",
1145                Some(Right::new(
1146                    Resource::Site,
1147                    Some("default/blog".into()),
1148                    Action::Read,
1149                )),
1150            ),
1151            (
1152                "PUT",
1153                "/api/sites/blog/aliases/www",
1154                Some(Right::new(
1155                    Resource::Site,
1156                    Some("default/blog".into()),
1157                    Action::Write,
1158                )),
1159            ),
1160            (
1161                "GET",
1162                "/api/sites/blog/aliases",
1163                Some(Right::new(
1164                    Resource::Site,
1165                    Some("default/blog".into()),
1166                    Action::Read,
1167                )),
1168            ),
1169            (
1170                "GET",
1171                "/api/sites/blog/_boatramp/handlers",
1172                Some(Right::new(
1173                    Resource::Site,
1174                    Some("default/blog".into()),
1175                    Action::Read,
1176                )),
1177            ),
1178            (
1179                "POST",
1180                "/api/tokens",
1181                Some(Right::new(Resource::Tokens, None, Action::Admin)),
1182            ),
1183            (
1184                "DELETE",
1185                "/api/tokens/t1",
1186                Some(Right::new(Resource::Tokens, None, Action::Admin)),
1187            ),
1188            (
1189                "GET",
1190                "/api/prune",
1191                Some(Right::new(Resource::System, None, Action::Admin)),
1192            ),
1193            (
1194                "POST",
1195                "/api/scrub",
1196                Some(Right::new(Resource::System, None, Action::Admin)),
1197            ),
1198            (
1199                "GET",
1200                "/api/certs",
1201                Some(Right::new(Resource::Certs, None, Action::Read)),
1202            ),
1203            (
1204                "POST",
1205                "/api/cache/invalidate",
1206                Some(Right::new(Resource::Cache, None, Action::Write)),
1207            ),
1208            (
1209                "GET",
1210                "/api/metrics",
1211                Some(Right::new(Resource::System, None, Action::Read)),
1212            ),
1213            // GraphQL administration is project-owned (default project for the global
1214            // path): read the surface with `project·read`, mutate it with
1215            // `project·deploy` — the same shape as functions/compute/workflows.
1216            (
1217                "GET",
1218                "/api/graphql/supergraph",
1219                Some(Right::new(
1220                    Resource::Project,
1221                    Some("default".into()),
1222                    Action::Read,
1223                )),
1224            ),
1225            (
1226                "PUT",
1227                "/api/graphql/subgraphs/catalog",
1228                Some(Right::new(
1229                    Resource::Project,
1230                    Some("default".into()),
1231                    Action::Deploy,
1232                )),
1233            ),
1234            (
1235                "POST",
1236                "/api/graphql/safelist",
1237                Some(Right::new(
1238                    Resource::Project,
1239                    Some("default".into()),
1240                    Action::Deploy,
1241                )),
1242            ),
1243            // Project-scoped internal secrets: the dedicated `Resource::Secrets`
1244            // (target = the project), listed with Read, mutated with Write — NOT the
1245            // general project-owned `Project·Deploy` mapping.
1246            (
1247                "GET",
1248                "/api/projects/acme/secrets",
1249                Some(Right::new(
1250                    Resource::Secrets,
1251                    Some("acme".into()),
1252                    Action::Read,
1253                )),
1254            ),
1255            (
1256                "POST",
1257                "/api/projects/acme/secrets",
1258                Some(Right::new(
1259                    Resource::Secrets,
1260                    Some("acme".into()),
1261                    Action::Write,
1262                )),
1263            ),
1264            (
1265                "DELETE",
1266                "/api/projects/acme/secrets/db-password",
1267                Some(Right::new(
1268                    Resource::Secrets,
1269                    Some("acme".into()),
1270                    Action::Write,
1271                )),
1272            ),
1273            // Project-scoped SMTP email profiles are credential config → the same
1274            // `Resource::Secrets` mapping as secrets (list/show Read, set/delete Write).
1275            (
1276                "GET",
1277                "/api/projects/acme/email/profiles",
1278                Some(Right::new(
1279                    Resource::Secrets,
1280                    Some("acme".into()),
1281                    Action::Read,
1282                )),
1283            ),
1284            (
1285                "PUT",
1286                "/api/projects/acme/email/profiles/default",
1287                Some(Right::new(
1288                    Resource::Secrets,
1289                    Some("acme".into()),
1290                    Action::Write,
1291                )),
1292            ),
1293            (
1294                "DELETE",
1295                "/api/projects/acme/email/profiles/default",
1296                Some(Right::new(
1297                    Resource::Secrets,
1298                    Some("acme".into()),
1299                    Action::Write,
1300                )),
1301            ),
1302        ];
1303        for (method, path, expected) in cases {
1304            assert_eq!(
1305                &Right::required(method, path),
1306                expected,
1307                "required({method}, {path})"
1308            );
1309        }
1310    }
1311
1312    #[test]
1313    fn unknown_site_subpath_is_deny_safe() {
1314        // An unmapped subpath must require system·admin, not the site's action.
1315        assert_eq!(
1316            Right::required("PATCH", "/api/sites/blog/frobnicate"),
1317            Some(Right::new(Resource::System, None, Action::Admin))
1318        );
1319    }
1320
1321    #[test]
1322    fn attach_unverified_is_admin_only() {
1323        // Attaching a host without a proof (`domain add --unverified`) must need
1324        // system·admin — a site-write right must NOT satisfy it, so a scoped
1325        // publisher can't claim an arbitrary host.
1326        let required = Right::required(
1327            "POST",
1328            "/api/sites/blog/domains/evil.example.com/attach-unverified",
1329        )
1330        .expect("route is gated");
1331        assert_eq!(required, Right::new(Resource::System, None, Action::Admin));
1332        // A publisher's site-write right does not satisfy the admin gate.
1333        let site_write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
1334        assert!(!site_write.satisfies(&required));
1335        // A system-admin right does.
1336        assert!(Right::new(Resource::System, None, Action::Admin).satisfies(&required));
1337    }
1338
1339    #[test]
1340    fn compute_volume_management_is_node_admin_only() {
1341        // `compute volume ls/rm` lists/removes volume directories across EVERY tenant
1342        // on the node, so it must require system·admin in both path forms — never the
1343        // per-project right the general `/api/compute/*` mapping gives. Otherwise a
1344        // project-scoped token could enumerate/destroy another tenant's volumes.
1345        let admin = Right::new(Resource::System, None, Action::Admin);
1346        for (method, path) in [
1347            // Direct (global) form.
1348            ("GET", "/api/compute/volumes"),
1349            ("DELETE", "/api/compute/volumes/pg-globex"),
1350            // Project-scoped form (the `project_scope` rewrite's original path).
1351            ("GET", "/api/projects/acme/compute/volumes"),
1352            ("DELETE", "/api/projects/acme/compute/volumes/pg-globex"),
1353        ] {
1354            let required = Right::required(method, path).expect("route is gated");
1355            assert_eq!(required, admin, "{method} {path} must need system·admin");
1356            // A cross-tenant project grant must NOT satisfy it — not even the target
1357            // project's own admin/publisher/viewer.
1358            for role in ["project_admin", "project_publisher", "project_viewer"] {
1359                let granted =
1360                    AuthzPolicy::default_policy().rights_for(&[GrantedRole::scoped(role, "acme")]);
1361                assert!(
1362                    !granted.allows(&required),
1363                    "{role}:acme must be refused on {method} {path}"
1364                );
1365            }
1366            // A node admin does satisfy it.
1367            assert!(admin.satisfies(&required));
1368        }
1369        // A sibling compute path that is NOT the volumes surface keeps its per-project
1370        // right (so this special-case didn't over-reach).
1371        assert_eq!(
1372            Right::required("GET", "/api/compute/volumesnap"),
1373            Some(Right::new(
1374                Resource::Project,
1375                Some(crate::project::DEFAULT_PROJECT.to_string()),
1376                Action::Read
1377            ))
1378        );
1379    }
1380
1381    #[test]
1382    fn compute_maintenance_tools_are_node_admin_only() {
1383        // The compute maintenance/diagnostic surface (status/ipam/dns/reconcile +
1384        // maintenance mutators) observes and overrides the reconcile plane across EVERY
1385        // tenant, so it must require system·admin in both path forms — never the
1386        // per-project right the general `/api/compute/*` mapping gives. Otherwise a
1387        // project-scoped token could read another tenant's replica state or flip its
1388        // health.
1389        let admin = Right::new(Resource::System, None, Action::Admin);
1390        for (method, path) in [
1391            // Direct (global) form.
1392            ("GET", "/api/compute/status"),
1393            ("GET", "/api/compute/ipam"),
1394            ("GET", "/api/compute/dns"),
1395            ("POST", "/api/compute/dns/resolve"),
1396            ("POST", "/api/compute/reconcile"),
1397            ("POST", "/api/compute/maintenance/restart"),
1398            ("POST", "/api/compute/maintenance/set-health"),
1399            ("POST", "/api/compute/maintenance/netdiag"),
1400            // Project-scoped form (the `project_scope` rewrite's original path).
1401            ("GET", "/api/projects/acme/compute/status"),
1402            ("POST", "/api/projects/acme/compute/maintenance/set-health"),
1403        ] {
1404            let required = Right::required(method, path).expect("route is gated");
1405            assert_eq!(required, admin, "{method} {path} must need system·admin");
1406            // No project grant — not even the target project's own admin — satisfies it.
1407            for role in ["project_admin", "project_publisher", "project_viewer"] {
1408                let granted =
1409                    AuthzPolicy::default_policy().rights_for(&[GrantedRole::scoped(role, "acme")]);
1410                assert!(
1411                    !granted.allows(&required),
1412                    "{role}:acme must be refused on {method} {path}"
1413                );
1414            }
1415            assert!(admin.satisfies(&required));
1416        }
1417        // A workload whose name merely STARTS WITH a reserved word (e.g. `statuspage`)
1418        // is not the maintenance surface — it keeps its per-project right.
1419        assert_eq!(
1420            Right::required("GET", "/api/compute/statuspage"),
1421            Some(Right::new(
1422                Resource::Project,
1423                Some(crate::project::DEFAULT_PROJECT.to_string()),
1424                Action::Read
1425            ))
1426        );
1427    }
1428
1429    #[test]
1430    fn default_policy_publisher_can_deploy_and_write_its_site_only() {
1431        let policy = AuthzPolicy::default_policy();
1432        let rights = policy.rights_for(&[GrantedRole::scoped("publisher", "blog")]);
1433        // Can read/write/deploy blog + upload blobs…
1434        assert!(rights.allows(&Right::new(
1435            Resource::Site,
1436            Some("blog".into()),
1437            Action::Deploy
1438        )));
1439        assert!(rights.allows(&Right::new(
1440            Resource::Site,
1441            Some("blog".into()),
1442            Action::Write
1443        )));
1444        assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1445        // …but not another site, nor token management.
1446        assert!(!rights.allows(&Right::new(
1447            Resource::Site,
1448            Some("api".into()),
1449            Action::Read
1450        )));
1451        assert!(!rights.allows(&Right::new(Resource::Tokens, None, Action::Admin)));
1452    }
1453
1454    #[test]
1455    fn default_policy_deployer_cannot_edit_config() {
1456        let policy = AuthzPolicy::default_policy();
1457        let rights = policy.rights_for(&[GrantedRole::scoped("deployer", "blog")]);
1458        assert!(rights.allows(&Right::new(
1459            Resource::Site,
1460            Some("blog".into()),
1461            Action::Deploy
1462        )));
1463        assert!(rights.allows(&Right::new(
1464            Resource::Site,
1465            Some("blog".into()),
1466            Action::Read
1467        )));
1468        assert!(
1469            !rights.allows(&Right::new(
1470                Resource::Site,
1471                Some("blog".into()),
1472                Action::Write
1473            )),
1474            "deployer must not edit config"
1475        );
1476    }
1477
1478    #[test]
1479    fn default_policy_admin_can_do_anything() {
1480        let policy = AuthzPolicy::default_policy();
1481        let rights = policy.rights_for(&[GrantedRole::global("admin")]);
1482        for resource in Resource::ALL {
1483            for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1484                let target = matches!(resource, Resource::Site).then(|| "any".to_string());
1485                assert!(
1486                    rights.allows(&Right::new(resource, target, action)),
1487                    "admin must allow {resource:?}·{action:?}"
1488                );
1489            }
1490        }
1491    }
1492
1493    #[test]
1494    fn site_role_without_target_grants_nothing_site_scoped() {
1495        let policy = AuthzPolicy::default_policy();
1496        // `publisher` granted globally (no target) — the site templates are
1497        // RoleTarget, so they contribute nothing; only the AnyTarget blobs right.
1498        let rights = policy.rights_for(&[GrantedRole::global("publisher")]);
1499        assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1500        assert!(!rights.allows(&Right::new(
1501            Resource::Site,
1502            Some("blog".into()),
1503            Action::Read
1504        )));
1505    }
1506
1507    #[test]
1508    fn role_takes_target_classifies_roles() {
1509        let policy = AuthzPolicy::default_policy();
1510        assert!(policy.role_takes_target("publisher"));
1511        assert!(policy.role_takes_target("viewer"));
1512        assert!(!policy.role_takes_target("admin"));
1513        assert!(!policy.role_takes_target("operator"));
1514    }
1515
1516    #[test]
1517    fn policy_round_trips_through_json() {
1518        let policy = AuthzPolicy::default_policy();
1519        let json = serde_json::to_string(&policy).unwrap();
1520        let back: AuthzPolicy = serde_json::from_str(&json).unwrap();
1521        assert_eq!(policy, back);
1522        assert_eq!(back.version, crate::SCHEMA_VERSION);
1523    }
1524
1525    #[test]
1526    fn unknown_role_is_ignored() {
1527        let policy = AuthzPolicy::default_policy();
1528        let rights = policy.rights_for(&[GrantedRole::global("nonesuch")]);
1529        assert!(rights.is_empty());
1530    }
1531
1532    // ---- 0.2.0 project scoping ---------------------------------------------
1533
1534    #[test]
1535    fn legacy_site_grant_normalizes_to_default_project() {
1536        let policy = AuthzPolicy::default_policy();
1537        // A pre-project token `publisher:blog` reads as the default project.
1538        let n = policy.normalize_grants(&[GrantedRole::scoped("publisher", "blog")]);
1539        assert_eq!(n, vec![GrantedRole::scoped("publisher", "default/blog")]);
1540        // An already-qualified site grant, a project grant, and a global grant pass
1541        // through untouched (a project name must NOT gain a `default/` prefix).
1542        let untouched = [
1543            GrantedRole::scoped("publisher", "acme/blog"),
1544            GrantedRole::scoped("project_admin", "acme"),
1545            GrantedRole::global("admin"),
1546        ];
1547        assert_eq!(policy.normalize_grants(&untouched), untouched);
1548    }
1549
1550    #[test]
1551    fn project_admin_covers_its_project_but_not_another() {
1552        let policy = AuthzPolicy::default_policy();
1553        let rights = policy.rights_for(&[GrantedRole::scoped("project_admin", "acme")]);
1554        // Every site in acme, at every action.
1555        for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1556            assert!(
1557                rights.allows(&Right::new(
1558                    Resource::Site,
1559                    Some("acme/blog".into()),
1560                    action
1561                )),
1562                "project-admin:acme covers acme/blog·{action:?}"
1563            );
1564        }
1565        // The project's own resources (functions/compute via Project).
1566        assert!(rights.allows(&Right::new(
1567            Resource::Project,
1568            Some("acme".into()),
1569            Action::Deploy
1570        )));
1571        assert!(rights.allows(&Right::new(
1572            Resource::Project,
1573            Some("acme".into()),
1574            Action::Admin
1575        )));
1576        // But NOT another project's sites or project resource — the tenant boundary.
1577        assert!(!rights.allows(&Right::new(
1578            Resource::Site,
1579            Some("shop/blog".into()),
1580            Action::Read
1581        )));
1582        assert!(!rights.allows(&Right::new(
1583            Resource::Project,
1584            Some("shop".into()),
1585            Action::Read
1586        )));
1587        // A bare/global site target is not covered by a project-scoped grant.
1588        assert!(!rights.allows(&Right::new(
1589            Resource::Site,
1590            Some("blog".into()),
1591            Action::Read
1592        )));
1593    }
1594
1595    #[test]
1596    fn secrets_are_managed_by_project_admin_only_and_project_scoped() {
1597        let policy = AuthzPolicy::default_policy();
1598
1599        // project_admin:acme manages acme's secrets (list + mutate, via Secrets·Admin)…
1600        let admin = policy.rights_for(&[GrantedRole::scoped("project_admin", "acme")]);
1601        for action in [Action::Read, Action::Write, Action::Admin] {
1602            assert!(
1603                admin.allows(&Right::new(Resource::Secrets, Some("acme".into()), action)),
1604                "project-admin:acme manages acme secrets·{action:?}"
1605            );
1606        }
1607        // …but NOT another project's secrets — the tenant boundary.
1608        assert!(!admin.allows(&Right::new(
1609            Resource::Secrets,
1610            Some("globex".into()),
1611            Action::Read
1612        )));
1613
1614        // A project publisher ships + configures but must NOT manage secrets
1615        // (credentials are admin-gated; a publisher only *references* boatramp:<name>).
1616        let publisher = policy.rights_for(&[GrantedRole::scoped("project_publisher", "acme")]);
1617        assert!(!publisher.allows(&Right::new(
1618            Resource::Secrets,
1619            Some("acme".into()),
1620            Action::Read
1621        )));
1622        assert!(!publisher.allows(&Right::new(
1623            Resource::Secrets,
1624            Some("acme".into()),
1625            Action::Write
1626        )));
1627
1628        // A project viewer can't even list secret names.
1629        let viewer = policy.rights_for(&[GrantedRole::scoped("project_viewer", "acme")]);
1630        assert!(!viewer.allows(&Right::new(
1631            Resource::Secrets,
1632            Some("acme".into()),
1633            Action::Read
1634        )));
1635
1636        // The global admin manages every project's secrets (via Resource::ALL).
1637        let root = policy.rights_for(&[GrantedRole::global("admin")]);
1638        assert!(root.allows(&Right::new(
1639            Resource::Secrets,
1640            Some("acme".into()),
1641            Action::Admin
1642        )));
1643        assert!(root.allows(&Right::new(
1644            Resource::Secrets,
1645            Some("globex".into()),
1646            Action::Write
1647        )));
1648    }
1649
1650    #[test]
1651    fn project_viewer_is_read_only_across_the_project() {
1652        let policy = AuthzPolicy::default_policy();
1653        let rights = policy.rights_for(&[GrantedRole::scoped("project_viewer", "acme")]);
1654        assert!(rights.allows(&Right::new(
1655            Resource::Site,
1656            Some("acme/blog".into()),
1657            Action::Read
1658        )));
1659        assert!(rights.allows(&Right::new(
1660            Resource::Project,
1661            Some("acme".into()),
1662            Action::Read
1663        )));
1664        // No writes/deploys anywhere.
1665        assert!(!rights.allows(&Right::new(
1666            Resource::Site,
1667            Some("acme/blog".into()),
1668            Action::Write
1669        )));
1670        assert!(!rights.allows(&Right::new(
1671            Resource::Project,
1672            Some("acme".into()),
1673            Action::Deploy
1674        )));
1675    }
1676
1677    #[test]
1678    fn project_publisher_ships_but_cannot_admin_the_project() {
1679        let policy = AuthzPolicy::default_policy();
1680        let rights = policy.rights_for(&[GrantedRole::scoped("project_publisher", "acme")]);
1681        assert!(rights.allows(&Right::new(
1682            Resource::Site,
1683            Some("acme/blog".into()),
1684            Action::Deploy
1685        )));
1686        assert!(rights.allows(&Right::new(
1687            Resource::Project,
1688            Some("acme".into()),
1689            Action::Deploy
1690        )));
1691        assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1692        // Admin of the project entity (membership/roles) is reserved for project-admin.
1693        assert!(!rights.allows(&Right::new(
1694            Resource::Project,
1695            Some("acme".into()),
1696            Action::Admin
1697        )));
1698    }
1699
1700    #[test]
1701    fn required_maps_project_paths() {
1702        // A site within a project.
1703        assert_eq!(
1704            Right::required("POST", "/api/projects/acme/sites/blog/deployments"),
1705            Some(Right::new(
1706                Resource::Site,
1707                Some("acme/blog".into()),
1708                Action::Deploy
1709            ))
1710        );
1711        // A project-owned resource (a function): read vs mutate.
1712        assert_eq!(
1713            Right::required("GET", "/api/projects/acme/functions/resize"),
1714            Some(Right::new(
1715                Resource::Project,
1716                Some("acme".into()),
1717                Action::Read
1718            ))
1719        );
1720        assert_eq!(
1721            Right::required("POST", "/api/projects/acme/functions/resize/versions"),
1722            Some(Right::new(
1723                Resource::Project,
1724                Some("acme".into()),
1725                Action::Deploy
1726            ))
1727        );
1728        // The project entity itself.
1729        assert_eq!(
1730            Right::required("DELETE", "/api/projects/acme"),
1731            Some(Right::new(
1732                Resource::Project,
1733                Some("acme".into()),
1734                Action::Admin
1735            ))
1736        );
1737        // Listing/creating projects is node-level.
1738        assert_eq!(
1739            Right::required("GET", "/api/projects"),
1740            Some(Right::new(Resource::System, None, Action::Read))
1741        );
1742        assert_eq!(
1743            Right::required("POST", "/api/projects"),
1744            Some(Right::new(Resource::System, None, Action::Admin))
1745        );
1746        // Legacy top-level functions map to the default project now.
1747        assert_eq!(
1748            Right::required("GET", "/api/functions"),
1749            Some(Right::new(
1750                Resource::Project,
1751                Some("default".into()),
1752                Action::Read
1753            ))
1754        );
1755    }
1756
1757    /// A function's captured-guest-logs endpoint is a project-owned READ — it reuses the
1758    /// `/api/functions/*` mapping (no dedicated right), so a project token reaches only
1759    /// its own functions' logs. Both the legacy default-project form and the
1760    /// project-scoped form resolve to `Project·Read` on the right project.
1761    #[test]
1762    fn function_logs_are_a_project_read() {
1763        // Legacy/default-project form.
1764        assert_eq!(
1765            Right::required("GET", "/api/functions/identity/_boatramp/logs"),
1766            Some(Right::new(
1767                Resource::Project,
1768                Some("default".into()),
1769                Action::Read
1770            ))
1771        );
1772        // Project-scoped form (the request the CLI sends under `--project acme`, before
1773        // `project_scope` rewrites it).
1774        assert_eq!(
1775            Right::required(
1776                "GET",
1777                "/api/projects/acme/functions/identity/_boatramp/logs"
1778            ),
1779            Some(Right::new(
1780                Resource::Project,
1781                Some("acme".into()),
1782                Action::Read
1783            ))
1784        );
1785        // Multi-tenant boundary: an `acme` project grant does not satisfy reading
1786        // `globex`'s function logs.
1787        let acme_read = AuthzPolicy::default_policy()
1788            .rights_for(&[GrantedRole::scoped("project_viewer", "acme")]);
1789        let globex_fn_logs = Right::required(
1790            "GET",
1791            "/api/projects/globex/functions/identity/_boatramp/logs",
1792        )
1793        .expect("gated");
1794        assert!(
1795            !acme_read.allows(&globex_fn_logs),
1796            "acme token must not read globex fn logs"
1797        );
1798    }
1799}