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