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. Two are **target-scoped**:
34/// [`Resource::Site`] (target = `"<project>/<site>"`, the 0.2.0 project-qualified
35/// form) and [`Resource::Project`] (target = `"<project>"`, governing the project's
36/// **own** resources — functions, compute, workflows, and the project entity itself).
37/// The rest are global.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Resource {
41    /// A single site (`target` = `"<project>/<site>"`): deployments, config, aliases, …
42    Site,
43    /// A project (`target` = `"<project>"`): its functions, compute, workflows, and
44    /// project-level config/CRUD. The owning + tenant boundary above a site.
45    Project,
46    /// Content-addressed blob uploads (`PUT /api/blobs/<hash>`).
47    Blobs,
48    /// API token management (`/api/tokens`).
49    Tokens,
50    /// TLS certificate status (`/api/certs`).
51    Certs,
52    /// Cache invalidation (`/api/cache/invalidate`).
53    Cache,
54    /// Node/system operations: metrics, prune, scrub, site listing.
55    System,
56}
57
58impl Resource {
59    /// Every resource variant — used to expand the `admin` role to "all rights".
60    pub const ALL: [Self; 7] = [
61        Self::Site,
62        Self::Project,
63        Self::Blobs,
64        Self::Tokens,
65        Self::Certs,
66        Self::Cache,
67        Self::System,
68    ];
69
70    /// The serde term for this resource (matches `rename_all`).
71    pub fn as_str(self) -> &'static str {
72        match self {
73            Self::Site => "site",
74            Self::Project => "project",
75            Self::Blobs => "blobs",
76            Self::Tokens => "tokens",
77            Self::Certs => "certs",
78            Self::Cache => "cache",
79            Self::System => "system",
80        }
81    }
82}
83
84impl Action {
85    /// The serde term for this action (matches `rename_all`).
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Self::Read => "read",
89            Self::Write => "write",
90            Self::Deploy => "deploy",
91            Self::Admin => "admin",
92        }
93    }
94}
95
96impl std::fmt::Display for Resource {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.write_str(self.as_str())
99    }
100}
101
102impl std::fmt::Display for Action {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str(self.as_str())
105    }
106}
107
108/// A single grant or requirement: an `action` on a `resource`, optionally scoped
109/// to a `target` (a site name for [`Resource::Site`]). A `target` of `None` on a
110/// *granted* right is a wildcard ("all targets"); a required right for a site
111/// always carries `Some(site)`.
112#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
113pub struct Right {
114    /// The resource class this right governs.
115    pub resource: Resource,
116    /// The site name for [`Resource::Site`]; `None` (wildcard/global) otherwise.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub target: Option<String>,
119    /// The permitted action.
120    pub action: Action,
121}
122
123impl Right {
124    /// Construct a right.
125    pub fn new(resource: Resource, target: Option<String>, action: Action) -> Self {
126        Self {
127            resource,
128            target,
129            action,
130        }
131    }
132
133    /// The target term: the site name, or `*` for a wildcard/global right.
134    pub fn target_term(&self) -> &str {
135        self.target.as_deref().unwrap_or("*")
136    }
137
138    /// Whether holding `self` (a *granted* right) satisfies a `required` right:
139    /// same resource, the granted action matches or is [`Action::Admin`], and the
140    /// granted target is a wildcard (`None`/`*`) or equals the required target.
141    pub fn satisfies(&self, required: &Self) -> bool {
142        self.resource == required.resource
143            && (self.action == required.action || self.action == Action::Admin)
144            && target_matches(self.target.as_deref(), required.target.as_deref())
145    }
146
147    /// Map an HTTP `method` + request `path` to the single right it requires, or
148    /// `None` for endpoints not gated by a right (the OIDC→token exchange).
149    ///
150    /// This is the authoritative request→right table. Unknown
151    /// `/api/sites/<s>/…` subpaths fall through to the most restrictive
152    /// `system · admin` so a narrow token can never reach an unmapped action.
153    pub fn required(method: &str, path: &str) -> Option<Self> {
154        let m = method.to_ascii_uppercase();
155        let get = m == "GET";
156
157        // Self-service endpoints gated only by holding *some* valid token, not a
158        // right: the OIDC→token exchange (carries an IdP JWT) and `whoami`
159        // (a principal reading its own identity). The handlers verify the token.
160        if path == "/api/auth/exchange" || path == "/api/auth/whoami" {
161            return None;
162        }
163
164        // Mesh join: the joiner presents a single-use *join
165        // token* (verified by the handler), not an admin bearer — so this exact
166        // path is unauthenticated at the RBAC layer. Note the `==`: the sibling
167        // `/api/cluster/join-token` (minting) stays admin-scoped via the default.
168        if path == "/api/cluster/join" {
169            return None;
170        }
171
172        // First-token bootstrap: the caller presents a single-use, operator-set
173        // *bootstrap secret* (verified by the handler), not an admin bearer — so
174        // this exact path is unauthenticated at the RBAC layer. Note the `==`: the
175        // sibling `/api/tokens` (minting) stays admin-scoped via the default below.
176        if path == "/api/tokens/bootstrap" {
177            return None;
178        }
179
180        // Content blobs are content-addressed (not site-specific); uploading is
181        // a deploy-grade action.
182        if path.starts_with("/api/blobs/") {
183            return Some(Self::new(Resource::Blobs, None, Action::Deploy));
184        }
185
186        // Attaching a host **without** an ownership proof (`domain add
187        // --unverified`) is an admin-only override: it asserts ownership of an
188        // arbitrary hostname, so a site-scoped publisher must never reach it
189        // (that would let them claim someone else's domain). Gate it at
190        // `system·admin` explicitly, above the per-site branch that would
191        // otherwise map it to the site-write right.
192        if m == "POST" && path.contains("/domains/") && path.ends_with("/attach-unverified") {
193            return Some(Self::new(Resource::System, None, Action::Admin));
194        }
195
196        // Project-scoped endpoints: `/api/projects/<proj>/<rest...>` (0.2.0). Parsed
197        // through the shared `project_api_path` so this and the request-scoping
198        // middleware agree on the tenant segment.
199        if let Some((proj, sub)) = project_api_path(path) {
200            if proj.is_empty() {
201                // `/api/projects/` (trailing slash) or a malformed `//…` — listing.
202                return Some(Self::new(Resource::System, None, Action::Read));
203            }
204            let sub: Vec<&str> = sub.split('/').filter(|s| !s.is_empty()).collect();
205            return Some(match sub.split_first() {
206                // The project entity itself: read, or manage (admin).
207                None => Self::new(
208                    Resource::Project,
209                    Some(proj.to_string()),
210                    if get { Action::Read } else { Action::Admin },
211                ),
212                // A site within the project: `.../sites/<site>/<site-sub...>`.
213                Some((&"sites", tail)) => {
214                    let site = tail.first().copied().unwrap_or("");
215                    if site.is_empty() {
216                        return Some(Self::new(
217                            Resource::Project,
218                            Some(proj.to_string()),
219                            Action::Read,
220                        ));
221                    }
222                    let site_sub: Vec<&str> = tail.iter().skip(1).copied().collect();
223                    match site_subpath_action(&m, get, &site_sub) {
224                        Some(a) => Self::new(Resource::Site, Some(format!("{proj}/{site}")), a),
225                        // Unknown subpath — deny-safe.
226                        None => Self::new(Resource::System, None, Action::Admin),
227                    }
228                }
229                // Project-owned resources (functions/compute/workflows/config/…):
230                // read with `Project·Read`, mutate with `Project·Deploy`.
231                Some(_) => Self::new(
232                    Resource::Project,
233                    Some(proj.to_string()),
234                    if get { Action::Read } else { Action::Deploy },
235                ),
236            });
237        }
238
239        // Legacy per-site endpoints: `/api/sites/<site>/<sub...>`. The site lives in
240        // the `default` project post-migration, so the target is project-qualified.
241        if let Some(rest) = path.strip_prefix("/api/sites/") {
242            let mut segs = rest.split('/');
243            let site = segs.next().unwrap_or("");
244            if site.is_empty() {
245                // `/api/sites/` (trailing slash) — listing.
246                return Some(Self::new(Resource::System, None, Action::Read));
247            }
248            let target = Some(format!("{}/{site}", crate::project::DEFAULT_PROJECT));
249            let sub: Vec<&str> = segs.filter(|s| !s.is_empty()).collect();
250            let action = site_subpath_action(&m, get, &sub);
251            return Some(match action {
252                Some(a) => Self::new(Resource::Site, target, a),
253                // Unknown subpath — deny-safe.
254                None => Self::new(Resource::System, None, Action::Admin),
255            });
256        }
257
258        // Exact, non-site endpoints.
259        let default_project = crate::project::DEFAULT_PROJECT.to_string();
260        let right = match path {
261            "/api/sites" => Self::new(Resource::System, None, Action::Read),
262            // Listing projects is a node-level read; creating one is a node-admin act
263            // (only `/api/projects` exactly — a specific project is handled above).
264            "/api/projects" => {
265                let action = if get { Action::Read } else { Action::Admin };
266                Self::new(Resource::System, None, action)
267            }
268            // Functions (FA-1/FA-2) are **project-owned** (0.2.0): read the view with
269            // `project·read`, mutate (deploy a version, alias, rollback, delete) with
270            // `project·deploy`, scoped to the default project for the legacy path.
271            p if p == "/api/functions" || p.starts_with("/api/functions/") => {
272                let action = if get { Action::Read } else { Action::Deploy };
273                Self::new(Resource::Project, Some(default_project.clone()), action)
274            }
275            // Workflows (FA-6) are project-owned too; same shape as `/api/functions`.
276            p if p == "/api/workflows" || p.starts_with("/api/workflows/") => {
277                let action = if get { Action::Read } else { Action::Deploy };
278                Self::new(Resource::Project, Some(default_project.clone()), action)
279            }
280            // Compute workloads (project-owned): read/deploy within the default project.
281            p if p == "/api/compute" || p.starts_with("/api/compute/") => {
282                let action = if get { Action::Read } else { Action::Deploy };
283                Self::new(Resource::Project, Some(default_project.clone()), action)
284            }
285            "/api/blobs" => Self::new(Resource::Blobs, None, Action::Deploy),
286            "/api/certs" => Self::new(Resource::Certs, None, Action::Read),
287            "/api/cache/invalidate" => Self::new(Resource::Cache, None, Action::Write),
288            "/api/metrics" => Self::new(Resource::System, None, Action::Read),
289            "/api/prune" | "/api/scrub" => Self::new(Resource::System, None, Action::Admin),
290            p if p == "/api/tokens" || p.starts_with("/api/tokens/") => {
291                Self::new(Resource::Tokens, None, Action::Admin)
292            }
293            p if p == "/api/authz/policy" || p.starts_with("/api/authz/") => {
294                Self::new(Resource::System, None, Action::Admin)
295            }
296            // Any other `/api/*` path: deny-safe (must hold system·admin).
297            _ => Self::new(Resource::System, None, Action::Admin),
298        };
299        Some(right)
300    }
301}
302
303/// The action a per-site subpath requires, or `None` if the subpath is unknown.
304fn site_subpath_action(method: &str, get: bool, sub: &[&str]) -> Option<Action> {
305    match sub.first().copied() {
306        // `deployments`, `deployments/<id>`, `deployments/<id>/activate`.
307        Some("deployments") => {
308            let activate = sub.last() == Some(&"activate");
309            if activate || method == "POST" {
310                Some(Action::Deploy) // activate, or create a deployment
311            } else if get {
312                Some(Action::Read)
313            } else {
314                None
315            }
316        }
317        Some("current") if get => Some(Action::Read),
318        Some("config") => {
319            if get {
320                Some(Action::Read)
321            } else if method == "PUT" {
322                Some(Action::Write)
323            } else {
324                None
325            }
326        }
327        // `domains/<host>/verification[/check]`, `domain-verifications`.
328        Some("domains") => {
329            let check = sub.last() == Some(&"check"); // a status check (POST, but read-grade)
330            if get || check {
331                Some(Action::Read)
332            } else if method == "POST" || method == "DELETE" {
333                Some(Action::Write)
334            } else {
335                None
336            }
337        }
338        Some("domain-verifications") if get => Some(Action::Read),
339        Some("aliases") => {
340            if get {
341                Some(Action::Read)
342            } else if method == "PUT" || method == "DELETE" {
343                Some(Action::Write)
344            } else {
345                None
346            }
347        }
348        // `_boatramp/handlers`, `_boatramp/logs` (per-site observability, read);
349        // `_boatramp/dlq` purge/redrive is a destructive site-scoped write.
350        Some("_boatramp") => {
351            if get {
352                Some(Action::Read)
353            } else if method == "POST" && sub.get(1) == Some(&"dlq") {
354                Some(Action::Write)
355            } else {
356                None
357            }
358        }
359        _ => None,
360    }
361}
362
363/// The project segment of a `"<project>/<site>"` target (the part before the first
364/// `/`), or the whole string when it carries no `/` (a bare project target).
365pub fn project_of(target: &str) -> &str {
366    target.split_once('/').map_or(target, |(p, _)| p)
367}
368
369/// Split an `/api/projects/<proj>/<sub…>` request path into its tenant project
370/// segment and the remaining sub-path, or `None` when the path is not
371/// project-scoped. `proj` is the first path segment after the prefix (possibly
372/// empty for a malformed `//…` or a trailing-slash `/api/projects/`); `sub` is
373/// everything after the first `/` (empty for the bare `/api/projects/<proj>` entity
374/// path).
375///
376/// Both the request-scoping middleware (`project_scope::scope_of`) and
377/// [`Right::required`] resolve the tenant through this one function, so the two can
378/// never disagree on which project a request targets — a confused-deputy hazard if
379/// they parsed it differently (each still applies its own policy to an empty
380/// segment: the middleware carries the default tenant, `Right::required` treats it
381/// as the listing/System right; both fail closed).
382pub fn project_api_path(path: &str) -> Option<(&str, &str)> {
383    let rest = path.strip_prefix("/api/projects/")?;
384    Some(rest.split_once('/').unwrap_or((rest, "")))
385}
386
387/// Whether a granted target covers a required target:
388/// - `None` — the global wildcard (an untargeted grant), covers everything;
389/// - `"<project>/*"` — a project wildcard, covers any `"<project>/<site>"` (the
390///   required target's project segment must equal `<project>`);
391/// - anything else — an exact string match.
392///
393/// A grant target of the literal string `"*"` is **not** a global wildcard: the
394/// wildcard is the *absence* of a target (`None`), so a `"*"` target matches only
395/// a resource literally named `*`. This keeps the pure oracle faithful to the
396/// Cedar authorizer, which likewise matches `"*"` as a literal set member — and a
397/// resource named `*` cannot be created (`validate_resource_name` rejects it).
398fn target_matches(granted: Option<&str>, required: Option<&str>) -> bool {
399    match granted {
400        None => true,
401        Some(g) => match g.strip_suffix("/*") {
402            Some(project) => required.is_some_and(|r| project_of(r) == project),
403            None => required == Some(g),
404        },
405    }
406}
407
408/// A set of granted [`Right`]s with the pure authorization decision. This is the
409/// pure-Rust reference decision: the differential oracle the Cedar authorizer is
410/// tested against, and used by issuance code that needs to reason about a role's
411/// effective rights.
412#[derive(Debug, Clone, Default, PartialEq, Eq)]
413pub struct RightSet {
414    rights: Vec<Right>,
415}
416
417impl RightSet {
418    /// An empty set (grants nothing).
419    pub fn new() -> Self {
420        Self::default()
421    }
422
423    /// Add a right (de-duplicated).
424    pub fn insert(&mut self, right: Right) {
425        if !self.rights.contains(&right) {
426            self.rights.push(right);
427        }
428    }
429
430    /// Whether any held right satisfies `required`.
431    pub fn allows(&self, required: &Right) -> bool {
432        self.rights.iter().any(|g| g.satisfies(required))
433    }
434
435    /// Whether the set grants nothing.
436    pub fn is_empty(&self) -> bool {
437        self.rights.is_empty()
438    }
439
440    /// The held rights.
441    pub fn rights(&self) -> &[Right] {
442        &self.rights
443    }
444}
445
446impl FromIterator<Right> for RightSet {
447    fn from_iter<I: IntoIterator<Item = Right>>(iter: I) -> Self {
448        let mut set = Self::new();
449        for r in iter {
450            set.insert(r);
451        }
452        set
453    }
454}
455
456/// What a target-scoped role's grant target names.
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub enum TargetKind {
459    /// A per-site role: target is `"<project>/<site>"`.
460    Site,
461    /// A per-project role: target is a bare `"<project>"`.
462    Project,
463}
464
465/// A role granted to a principal: a role `name` from the [`AuthzPolicy`], plus an
466/// optional `target` for target-scoped roles — a `"<project>/<site>"` for a site
467/// role, or a bare `"<project>"` for a project role.
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469pub struct GrantedRole {
470    /// The role name (a key in [`AuthzPolicy::roles`]).
471    pub name: String,
472    /// The site this instance is scoped to, for target-scoped roles.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub target: Option<String>,
475}
476
477impl GrantedRole {
478    /// A global role (no target).
479    pub fn global(name: impl Into<String>) -> Self {
480        Self {
481            name: name.into(),
482            target: None,
483        }
484    }
485
486    /// A target-scoped role (e.g. `publisher` on a site).
487    pub fn scoped(name: impl Into<String>, target: impl Into<String>) -> Self {
488        Self {
489            name: name.into(),
490            target: Some(target.into()),
491        }
492    }
493
494    /// Parse a role spec: `"<role>"` (global) or `"<role>:<target>"`
495    /// (target-scoped). Used by the CLI `--role`, the API token-create body, and
496    /// the OIDC claim→roles mapping, so they agree on the format.
497    pub fn parse(spec: &str) -> Self {
498        match spec.split_once(':') {
499            Some((name, target)) if !target.trim().is_empty() => {
500                Self::scoped(name.trim(), target.trim())
501            }
502            _ => Self::global(spec.trim()),
503        }
504    }
505}
506
507/// How a [`RightTemplate`] derives its target when expanding a role.
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510pub enum TargetScope {
511    /// Wildcard/global: the expanded right has `target = None`.
512    AnyTarget,
513    /// Bind to the granted role instance's target verbatim (a site role's
514    /// `"<project>/<site>"`, or a project role's `"<project>"`).
515    RoleTarget,
516    /// A **project role** granting a per-site right over *every* site in its project:
517    /// the granted target is a bare project name `"<project>"` and the expanded right
518    /// gets the project-wildcard target `"<project>/*"`, which
519    /// [`target_matches`](Right::satisfies) treats as covering any `"<project>/<site>"`.
520    ProjectWildcard,
521}
522
523impl TargetScope {
524    /// Whether this scope binds a target (so its role is target-scoped).
525    pub fn is_targeted(self) -> bool {
526        matches!(self, Self::RoleTarget | Self::ProjectWildcard)
527    }
528}
529
530/// One right a role grants, before binding to a concrete target.
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
532pub struct RightTemplate {
533    /// The resource the right governs.
534    pub resource: Resource,
535    /// The action granted.
536    pub action: Action,
537    /// How the target is derived when the role is expanded.
538    pub scope: TargetScope,
539}
540
541impl RightTemplate {
542    /// A global right template (`AnyTarget`).
543    pub fn any(resource: Resource, action: Action) -> Self {
544        Self {
545            resource,
546            action,
547            scope: TargetScope::AnyTarget,
548        }
549    }
550
551    /// A target-scoped right template (`RoleTarget`).
552    pub fn scoped(resource: Resource, action: Action) -> Self {
553        Self {
554            resource,
555            action,
556            scope: TargetScope::RoleTarget,
557        }
558    }
559
560    /// A project-wildcard right template (`ProjectWildcard`): a per-site right a
561    /// project role confers over every site in the project.
562    pub fn project_wildcard(resource: Resource, action: Action) -> Self {
563        Self {
564            resource,
565            action,
566            scope: TargetScope::ProjectWildcard,
567        }
568    }
569}
570
571/// The RBAC policy: roles → the rights they grant. Stored at KV `authz/policy`
572/// (schema v1); when absent the server uses [`AuthzPolicy::default_policy`].
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574#[serde(deny_unknown_fields)]
575pub struct AuthzPolicy {
576    /// Pinned schema discriminant (`v1`).
577    #[serde(default = "crate::schema_version")]
578    pub version: u32,
579    /// Role name → the right templates it grants.
580    pub roles: BTreeMap<String, Vec<RightTemplate>>,
581}
582
583impl Default for AuthzPolicy {
584    fn default() -> Self {
585        Self::default_policy()
586    }
587}
588
589impl AuthzPolicy {
590    /// The built-in default policy: `admin`, `publisher`,
591    /// `deployer`, `viewer`, `operator`.
592    pub fn default_policy() -> Self {
593        let mut roles: BTreeMap<String, Vec<RightTemplate>> = BTreeMap::new();
594
595        // admin — every (resource, action). Expanded as one Admin right per
596        // resource with a wildcard target.
597        roles.insert(
598            "admin".to_string(),
599            Resource::ALL
600                .iter()
601                .map(|&r| RightTemplate::any(r, Action::Admin))
602                .collect(),
603        );
604
605        // publisher (site) — full control of its site + blob uploads.
606        roles.insert(
607            "publisher".to_string(),
608            vec![
609                RightTemplate::scoped(Resource::Site, Action::Read),
610                RightTemplate::scoped(Resource::Site, Action::Write),
611                RightTemplate::scoped(Resource::Site, Action::Deploy),
612                RightTemplate::any(Resource::Blobs, Action::Deploy),
613            ],
614        );
615
616        // deployer (site) — ship + read, but not edit config.
617        roles.insert(
618            "deployer".to_string(),
619            vec![
620                RightTemplate::scoped(Resource::Site, Action::Read),
621                RightTemplate::scoped(Resource::Site, Action::Deploy),
622                RightTemplate::any(Resource::Blobs, Action::Deploy),
623            ],
624        );
625
626        // viewer (site) — read-only on its site.
627        roles.insert(
628            "viewer".to_string(),
629            vec![RightTemplate::scoped(Resource::Site, Action::Read)],
630        );
631
632        // operator — node-level read + cache control, no site access.
633        roles.insert(
634            "operator".to_string(),
635            vec![
636                RightTemplate::any(Resource::System, Action::Read),
637                RightTemplate::any(Resource::Certs, Action::Read),
638                RightTemplate::any(Resource::Cache, Action::Write),
639            ],
640        );
641
642        // project-admin (project) — full control of the project: its own resources
643        // (functions/compute/workflows/config, via `Project·Admin`) AND every site in
644        // it (`Site·Admin` over the project wildcard) + blob uploads.
645        roles.insert(
646            "project_admin".to_string(),
647            vec![
648                RightTemplate::scoped(Resource::Project, Action::Admin),
649                RightTemplate::project_wildcard(Resource::Site, Action::Admin),
650                RightTemplate::any(Resource::Blobs, Action::Deploy),
651            ],
652        );
653
654        // project-publisher (project) — ship + configure any site in the project and
655        // manage its functions/compute (write + deploy + read), but not admin the
656        // project entity (no membership/role changes).
657        roles.insert(
658            "project_publisher".to_string(),
659            vec![
660                RightTemplate::scoped(Resource::Project, Action::Read),
661                RightTemplate::scoped(Resource::Project, Action::Write),
662                RightTemplate::scoped(Resource::Project, Action::Deploy),
663                RightTemplate::project_wildcard(Resource::Site, Action::Read),
664                RightTemplate::project_wildcard(Resource::Site, Action::Write),
665                RightTemplate::project_wildcard(Resource::Site, Action::Deploy),
666                RightTemplate::any(Resource::Blobs, Action::Deploy),
667            ],
668        );
669
670        // project-viewer (project) — read-only across the whole project.
671        roles.insert(
672            "project_viewer".to_string(),
673            vec![
674                RightTemplate::scoped(Resource::Project, Action::Read),
675                RightTemplate::project_wildcard(Resource::Site, Action::Read),
676            ],
677        );
678
679        Self {
680            version: crate::SCHEMA_VERSION,
681            roles,
682        }
683    }
684
685    /// Whether `role` is target-scoped (any of its templates binds the target).
686    pub fn role_takes_target(&self, role: &str) -> bool {
687        self.roles
688            .get(role)
689            .is_some_and(|ts| ts.iter().any(|t| t.scope.is_targeted()))
690    }
691
692    /// What kind of target a role's grant carries: a per-site `"<project>/<site>"`
693    /// ([`TargetKind::Site`], a role with a Site `RoleTarget` template) or a bare
694    /// `"<project>"` ([`TargetKind::Project`], a role with a `ProjectWildcard` or a
695    /// non-Site `RoleTarget` template). `None` for a global role. Used to normalize a
696    /// legacy site-only grant to the `default` project ([`normalize_grants`]).
697    ///
698    /// [`normalize_grants`]: Self::normalize_grants
699    pub fn role_target_kind(&self, role: &str) -> Option<TargetKind> {
700        let templates = self.roles.get(role)?;
701        let mut site = false;
702        let mut project = false;
703        for t in templates {
704            match t.scope {
705                TargetScope::RoleTarget if t.resource == Resource::Site => site = true,
706                TargetScope::RoleTarget => project = true,
707                TargetScope::ProjectWildcard => project = true,
708                TargetScope::AnyTarget => {}
709            }
710        }
711        // A Site `RoleTarget` role is per-site; a project role (ProjectWildcard, or a
712        // `RoleTarget` on the Project resource) is per-project.
713        if site {
714            Some(TargetKind::Site)
715        } else if project {
716            Some(TargetKind::Project)
717        } else {
718            None
719        }
720    }
721
722    /// Normalize legacy grants for 0.2.0: a **site** role granted a bare target with
723    /// no project segment (a pre-project token, e.g. `publisher:blog`) is read as the
724    /// `default` project (`publisher:default/blog`). Project and global roles, and any
725    /// already-qualified `"<project>/<site>"` target, pass through unchanged. Run once
726    /// at token→roles ingestion, before either [`rights_for`](Self::rights_for) or the
727    /// Cedar authorizer, so both decide on the same normalized grants.
728    pub fn normalize_grants(&self, roles: &[GrantedRole]) -> Vec<GrantedRole> {
729        roles
730            .iter()
731            .map(|g| match (&g.target, self.role_target_kind(&g.name)) {
732                (Some(t), Some(TargetKind::Site)) if !t.contains('/') => {
733                    GrantedRole::scoped(&g.name, format!("{}/{t}", crate::project::DEFAULT_PROJECT))
734                }
735                _ => g.clone(),
736            })
737            .collect()
738    }
739
740    /// Expand a principal's granted roles into the concrete [`RightSet`] they
741    /// confer under this policy. A target-scoped template on a role granted
742    /// without a target contributes nothing (defensive). This is the pure RBAC
743    /// expansion the Cedar authorizer reproduces as a policy set. Callers pass
744    /// grants already normalized by [`normalize_grants`](Self::normalize_grants).
745    pub fn rights_for(&self, roles: &[GrantedRole]) -> RightSet {
746        let mut set = RightSet::new();
747        for granted in roles {
748            let Some(templates) = self.roles.get(&granted.name) else {
749                continue;
750            };
751            for t in templates {
752                let target = match t.scope {
753                    TargetScope::AnyTarget => None,
754                    TargetScope::RoleTarget => match &granted.target {
755                        Some(x) => Some(x.clone()),
756                        None => continue,
757                    },
758                    // A project role's per-site right covers every site in its
759                    // project: expand the bare project target to `"<project>/*"`.
760                    TargetScope::ProjectWildcard => match &granted.target {
761                        Some(x) => Some(format!("{x}/*")),
762                        None => continue,
763                    },
764                };
765                set.insert(Right::new(t.resource, target, t.action));
766            }
767        }
768        set
769    }
770}
771
772/// KV key for the RBAC policy document (`authz/policy`); absent ⇒ the built-in
773/// [`AuthzPolicy::default_policy`].
774pub const POLICY_KEY: &str = "authz/policy";
775
776/// KV key prefix for revocation markers — presence of `authz/revoked/<id>`
777/// means the token with authority revocation id `<id>` (and its attenuations)
778/// is revoked.
779pub const REVOKED_PREFIX: &str = "authz/revoked/";
780
781/// KV key prefix for issued-token metadata (`authz/tokens/<id>`). The token
782/// itself is never stored — only this metadata, for `token ls`.
783pub const TOKEN_META_PREFIX: &str = "authz/tokens/";
784
785/// The revocation-marker key for an authority revocation id.
786pub fn revoked_key(revocation_id: &str) -> String {
787    format!("{REVOKED_PREFIX}{revocation_id}")
788}
789
790/// KV key prefix for extra trusted **root anchors** added by `auth rotate-root`
791/// (`auth/root/{alg:hex}`). Each is a `TokenPublicKey` trusted alongside the
792/// configured primary root during a make-before-break root rotation.
793pub const ROOT_ANCHOR_PREFIX: &str = "auth/root/";
794
795/// The root-anchor key trusting `pubkey` (an `alg:hex`-encoded `TokenPublicKey`).
796pub fn root_anchor_key(pubkey: &str) -> String {
797    format!("{ROOT_ANCHOR_PREFIX}{pubkey}")
798}
799
800/// The metadata key for an issued token (keyed by its authority revocation id).
801pub fn token_meta_key(id: &str) -> String {
802    format!("{TOKEN_META_PREFIX}{id}")
803}
804
805/// The single-use marker key for a redeemed first-token bootstrap secret
806/// (keyed by the secret's SHA-256 hex).
807pub fn bootstrap_key(secret_hash: &str) -> String {
808    format!("authz/bootstrap/{secret_hash}")
809}
810
811/// Metadata for an issued token (`authz/tokens/<id>`). The token itself is
812/// shown once at creation and never stored; this is what `token ls` reports and
813/// what `token rm` needs to find the revocation id. Schema v1.
814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
815pub struct TokenMeta {
816    /// Pinned schema discriminant (`v1`).
817    #[serde(default = "crate::schema_version")]
818    pub version: u32,
819    /// Human label for the token.
820    pub label: String,
821    /// The roles the token grants.
822    pub roles: Vec<GrantedRole>,
823    /// Unix timestamp (seconds) of creation.
824    pub created_at: u64,
825    /// Unix timestamp (seconds) of expiry, if the token carries a TTL.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub expires_at: Option<u64>,
828    /// The authority revocation id (hex) — also the `authz/tokens/<id>` key and
829    /// the argument to `token rm`.
830    pub revocation_id: String,
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    #[test]
838    fn admin_satisfies_every_action_on_its_resource() {
839        let admin_site = Right::new(Resource::Site, None, Action::Admin);
840        for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
841            let required = Right::new(Resource::Site, Some("blog".into()), action);
842            assert!(
843                admin_site.satisfies(&required),
844                "admin must satisfy {action:?}"
845            );
846        }
847        // …but not a different resource.
848        assert!(!admin_site.satisfies(&Right::new(Resource::Tokens, None, Action::Read)));
849    }
850
851    #[test]
852    fn target_scoping_is_exact_unless_wildcard() {
853        let blog = Right::new(Resource::Site, Some("blog".into()), Action::Write);
854        assert!(blog.satisfies(&Right::new(
855            Resource::Site,
856            Some("blog".into()),
857            Action::Write
858        )));
859        assert!(!blog.satisfies(&Right::new(
860            Resource::Site,
861            Some("api".into()),
862            Action::Write
863        )));
864        // A wildcard grant covers any site.
865        let any = Right::new(Resource::Site, None, Action::Write);
866        assert!(any.satisfies(&Right::new(
867            Resource::Site,
868            Some("api".into()),
869            Action::Write
870        )));
871    }
872
873    #[test]
874    fn distinct_actions_do_not_imply_each_other() {
875        let write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
876        let deploy_req = Right::new(Resource::Site, Some("blog".into()), Action::Deploy);
877        assert!(
878            !write.satisfies(&deploy_req),
879            "write must not imply deploy (only admin does)"
880        );
881    }
882
883    /// Every row of the request→right table.
884    #[test]
885    fn required_right_table() {
886        let cases: &[(&str, &str, Option<Right>)] = &[
887            ("POST", "/api/auth/exchange", None),
888            ("GET", "/api/auth/whoami", None),
889            // Minting a mesh join token is admin-scoped (deny-safe default for
890            // `/api/cluster/*`) — an operator issues it.
891            (
892                "POST",
893                "/api/cluster/join-token",
894                Some(Right::new(Resource::System, None, Action::Admin)),
895            ),
896            // Presenting a join token to join is gated by the token itself, not
897            // an admin bearer (the handler verifies it) — exact-path `None`.
898            ("POST", "/api/cluster/join", None),
899            // Rotating this node's mesh key is an operator action → admin-scoped.
900            (
901                "POST",
902                "/api/cluster/rotate-key",
903                Some(Right::new(Resource::System, None, Action::Admin)),
904            ),
905            // Revoking a node from the mesh is an operator action → admin-scoped.
906            (
907                "POST",
908                "/api/cluster/revoke",
909                Some(Right::new(Resource::System, None, Action::Admin)),
910            ),
911            (
912                "PUT",
913                "/api/blobs/abc123",
914                Some(Right::new(Resource::Blobs, None, Action::Deploy)),
915            ),
916            (
917                "GET",
918                "/api/sites",
919                Some(Right::new(Resource::System, None, Action::Read)),
920            ),
921            (
922                "POST",
923                "/api/sites/blog/deployments",
924                Some(Right::new(
925                    Resource::Site,
926                    Some("default/blog".into()),
927                    Action::Deploy,
928                )),
929            ),
930            (
931                "GET",
932                "/api/sites/blog/deployments",
933                Some(Right::new(
934                    Resource::Site,
935                    Some("default/blog".into()),
936                    Action::Read,
937                )),
938            ),
939            (
940                "GET",
941                "/api/sites/blog/deployments/d1",
942                Some(Right::new(
943                    Resource::Site,
944                    Some("default/blog".into()),
945                    Action::Read,
946                )),
947            ),
948            (
949                "POST",
950                "/api/sites/blog/deployments/d1/activate",
951                Some(Right::new(
952                    Resource::Site,
953                    Some("default/blog".into()),
954                    Action::Deploy,
955                )),
956            ),
957            (
958                "GET",
959                "/api/sites/blog/current",
960                Some(Right::new(
961                    Resource::Site,
962                    Some("default/blog".into()),
963                    Action::Read,
964                )),
965            ),
966            (
967                "GET",
968                "/api/sites/blog/config",
969                Some(Right::new(
970                    Resource::Site,
971                    Some("default/blog".into()),
972                    Action::Read,
973                )),
974            ),
975            (
976                "PUT",
977                "/api/sites/blog/config",
978                Some(Right::new(
979                    Resource::Site,
980                    Some("default/blog".into()),
981                    Action::Write,
982                )),
983            ),
984            (
985                "GET",
986                "/api/sites/blog/domains/x.example.com/verification",
987                Some(Right::new(
988                    Resource::Site,
989                    Some("default/blog".into()),
990                    Action::Read,
991                )),
992            ),
993            (
994                "POST",
995                "/api/sites/blog/domains/x.example.com/verification",
996                Some(Right::new(
997                    Resource::Site,
998                    Some("default/blog".into()),
999                    Action::Write,
1000                )),
1001            ),
1002            (
1003                "DELETE",
1004                "/api/sites/blog/domains/x.example.com/verification",
1005                Some(Right::new(
1006                    Resource::Site,
1007                    Some("default/blog".into()),
1008                    Action::Write,
1009                )),
1010            ),
1011            (
1012                "POST",
1013                "/api/sites/blog/domains/x.example.com/verification/check",
1014                Some(Right::new(
1015                    Resource::Site,
1016                    Some("default/blog".into()),
1017                    Action::Read,
1018                )),
1019            ),
1020            (
1021                "GET",
1022                "/api/sites/blog/domain-verifications",
1023                Some(Right::new(
1024                    Resource::Site,
1025                    Some("default/blog".into()),
1026                    Action::Read,
1027                )),
1028            ),
1029            (
1030                "PUT",
1031                "/api/sites/blog/aliases/www",
1032                Some(Right::new(
1033                    Resource::Site,
1034                    Some("default/blog".into()),
1035                    Action::Write,
1036                )),
1037            ),
1038            (
1039                "GET",
1040                "/api/sites/blog/aliases",
1041                Some(Right::new(
1042                    Resource::Site,
1043                    Some("default/blog".into()),
1044                    Action::Read,
1045                )),
1046            ),
1047            (
1048                "GET",
1049                "/api/sites/blog/_boatramp/handlers",
1050                Some(Right::new(
1051                    Resource::Site,
1052                    Some("default/blog".into()),
1053                    Action::Read,
1054                )),
1055            ),
1056            (
1057                "POST",
1058                "/api/tokens",
1059                Some(Right::new(Resource::Tokens, None, Action::Admin)),
1060            ),
1061            (
1062                "DELETE",
1063                "/api/tokens/t1",
1064                Some(Right::new(Resource::Tokens, None, Action::Admin)),
1065            ),
1066            (
1067                "GET",
1068                "/api/prune",
1069                Some(Right::new(Resource::System, None, Action::Admin)),
1070            ),
1071            (
1072                "POST",
1073                "/api/scrub",
1074                Some(Right::new(Resource::System, None, Action::Admin)),
1075            ),
1076            (
1077                "GET",
1078                "/api/certs",
1079                Some(Right::new(Resource::Certs, None, Action::Read)),
1080            ),
1081            (
1082                "POST",
1083                "/api/cache/invalidate",
1084                Some(Right::new(Resource::Cache, None, Action::Write)),
1085            ),
1086            (
1087                "GET",
1088                "/api/metrics",
1089                Some(Right::new(Resource::System, None, Action::Read)),
1090            ),
1091        ];
1092        for (method, path, expected) in cases {
1093            assert_eq!(
1094                &Right::required(method, path),
1095                expected,
1096                "required({method}, {path})"
1097            );
1098        }
1099    }
1100
1101    #[test]
1102    fn unknown_site_subpath_is_deny_safe() {
1103        // An unmapped subpath must require system·admin, not the site's action.
1104        assert_eq!(
1105            Right::required("PATCH", "/api/sites/blog/frobnicate"),
1106            Some(Right::new(Resource::System, None, Action::Admin))
1107        );
1108    }
1109
1110    #[test]
1111    fn attach_unverified_is_admin_only() {
1112        // Attaching a host without a proof (`domain add --unverified`) must need
1113        // system·admin — a site-write right must NOT satisfy it, so a scoped
1114        // publisher can't claim an arbitrary host.
1115        let required = Right::required(
1116            "POST",
1117            "/api/sites/blog/domains/evil.example.com/attach-unverified",
1118        )
1119        .expect("route is gated");
1120        assert_eq!(required, Right::new(Resource::System, None, Action::Admin));
1121        // A publisher's site-write right does not satisfy the admin gate.
1122        let site_write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
1123        assert!(!site_write.satisfies(&required));
1124        // A system-admin right does.
1125        assert!(Right::new(Resource::System, None, Action::Admin).satisfies(&required));
1126    }
1127
1128    #[test]
1129    fn default_policy_publisher_can_deploy_and_write_its_site_only() {
1130        let policy = AuthzPolicy::default_policy();
1131        let rights = policy.rights_for(&[GrantedRole::scoped("publisher", "blog")]);
1132        // Can read/write/deploy blog + upload blobs…
1133        assert!(rights.allows(&Right::new(
1134            Resource::Site,
1135            Some("blog".into()),
1136            Action::Deploy
1137        )));
1138        assert!(rights.allows(&Right::new(
1139            Resource::Site,
1140            Some("blog".into()),
1141            Action::Write
1142        )));
1143        assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1144        // …but not another site, nor token management.
1145        assert!(!rights.allows(&Right::new(
1146            Resource::Site,
1147            Some("api".into()),
1148            Action::Read
1149        )));
1150        assert!(!rights.allows(&Right::new(Resource::Tokens, None, Action::Admin)));
1151    }
1152
1153    #[test]
1154    fn default_policy_deployer_cannot_edit_config() {
1155        let policy = AuthzPolicy::default_policy();
1156        let rights = policy.rights_for(&[GrantedRole::scoped("deployer", "blog")]);
1157        assert!(rights.allows(&Right::new(
1158            Resource::Site,
1159            Some("blog".into()),
1160            Action::Deploy
1161        )));
1162        assert!(rights.allows(&Right::new(
1163            Resource::Site,
1164            Some("blog".into()),
1165            Action::Read
1166        )));
1167        assert!(
1168            !rights.allows(&Right::new(
1169                Resource::Site,
1170                Some("blog".into()),
1171                Action::Write
1172            )),
1173            "deployer must not edit config"
1174        );
1175    }
1176
1177    #[test]
1178    fn default_policy_admin_can_do_anything() {
1179        let policy = AuthzPolicy::default_policy();
1180        let rights = policy.rights_for(&[GrantedRole::global("admin")]);
1181        for resource in Resource::ALL {
1182            for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1183                let target = matches!(resource, Resource::Site).then(|| "any".to_string());
1184                assert!(
1185                    rights.allows(&Right::new(resource, target, action)),
1186                    "admin must allow {resource:?}·{action:?}"
1187                );
1188            }
1189        }
1190    }
1191
1192    #[test]
1193    fn site_role_without_target_grants_nothing_site_scoped() {
1194        let policy = AuthzPolicy::default_policy();
1195        // `publisher` granted globally (no target) — the site templates are
1196        // RoleTarget, so they contribute nothing; only the AnyTarget blobs right.
1197        let rights = policy.rights_for(&[GrantedRole::global("publisher")]);
1198        assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1199        assert!(!rights.allows(&Right::new(
1200            Resource::Site,
1201            Some("blog".into()),
1202            Action::Read
1203        )));
1204    }
1205
1206    #[test]
1207    fn role_takes_target_classifies_roles() {
1208        let policy = AuthzPolicy::default_policy();
1209        assert!(policy.role_takes_target("publisher"));
1210        assert!(policy.role_takes_target("viewer"));
1211        assert!(!policy.role_takes_target("admin"));
1212        assert!(!policy.role_takes_target("operator"));
1213    }
1214
1215    #[test]
1216    fn policy_round_trips_through_json() {
1217        let policy = AuthzPolicy::default_policy();
1218        let json = serde_json::to_string(&policy).unwrap();
1219        let back: AuthzPolicy = serde_json::from_str(&json).unwrap();
1220        assert_eq!(policy, back);
1221        assert_eq!(back.version, crate::SCHEMA_VERSION);
1222    }
1223
1224    #[test]
1225    fn unknown_role_is_ignored() {
1226        let policy = AuthzPolicy::default_policy();
1227        let rights = policy.rights_for(&[GrantedRole::global("nonesuch")]);
1228        assert!(rights.is_empty());
1229    }
1230
1231    // ---- 0.2.0 project scoping ---------------------------------------------
1232
1233    #[test]
1234    fn legacy_site_grant_normalizes_to_default_project() {
1235        let policy = AuthzPolicy::default_policy();
1236        // A pre-project token `publisher:blog` reads as the default project.
1237        let n = policy.normalize_grants(&[GrantedRole::scoped("publisher", "blog")]);
1238        assert_eq!(n, vec![GrantedRole::scoped("publisher", "default/blog")]);
1239        // An already-qualified site grant, a project grant, and a global grant pass
1240        // through untouched (a project name must NOT gain a `default/` prefix).
1241        let untouched = [
1242            GrantedRole::scoped("publisher", "acme/blog"),
1243            GrantedRole::scoped("project_admin", "acme"),
1244            GrantedRole::global("admin"),
1245        ];
1246        assert_eq!(policy.normalize_grants(&untouched), untouched);
1247    }
1248
1249    #[test]
1250    fn project_admin_covers_its_project_but_not_another() {
1251        let policy = AuthzPolicy::default_policy();
1252        let rights = policy.rights_for(&[GrantedRole::scoped("project_admin", "acme")]);
1253        // Every site in acme, at every action.
1254        for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1255            assert!(
1256                rights.allows(&Right::new(
1257                    Resource::Site,
1258                    Some("acme/blog".into()),
1259                    action
1260                )),
1261                "project-admin:acme covers acme/blog·{action:?}"
1262            );
1263        }
1264        // The project's own resources (functions/compute via Project).
1265        assert!(rights.allows(&Right::new(
1266            Resource::Project,
1267            Some("acme".into()),
1268            Action::Deploy
1269        )));
1270        assert!(rights.allows(&Right::new(
1271            Resource::Project,
1272            Some("acme".into()),
1273            Action::Admin
1274        )));
1275        // But NOT another project's sites or project resource — the tenant boundary.
1276        assert!(!rights.allows(&Right::new(
1277            Resource::Site,
1278            Some("shop/blog".into()),
1279            Action::Read
1280        )));
1281        assert!(!rights.allows(&Right::new(
1282            Resource::Project,
1283            Some("shop".into()),
1284            Action::Read
1285        )));
1286        // A bare/global site target is not covered by a project-scoped grant.
1287        assert!(!rights.allows(&Right::new(
1288            Resource::Site,
1289            Some("blog".into()),
1290            Action::Read
1291        )));
1292    }
1293
1294    #[test]
1295    fn project_viewer_is_read_only_across_the_project() {
1296        let policy = AuthzPolicy::default_policy();
1297        let rights = policy.rights_for(&[GrantedRole::scoped("project_viewer", "acme")]);
1298        assert!(rights.allows(&Right::new(
1299            Resource::Site,
1300            Some("acme/blog".into()),
1301            Action::Read
1302        )));
1303        assert!(rights.allows(&Right::new(
1304            Resource::Project,
1305            Some("acme".into()),
1306            Action::Read
1307        )));
1308        // No writes/deploys anywhere.
1309        assert!(!rights.allows(&Right::new(
1310            Resource::Site,
1311            Some("acme/blog".into()),
1312            Action::Write
1313        )));
1314        assert!(!rights.allows(&Right::new(
1315            Resource::Project,
1316            Some("acme".into()),
1317            Action::Deploy
1318        )));
1319    }
1320
1321    #[test]
1322    fn project_publisher_ships_but_cannot_admin_the_project() {
1323        let policy = AuthzPolicy::default_policy();
1324        let rights = policy.rights_for(&[GrantedRole::scoped("project_publisher", "acme")]);
1325        assert!(rights.allows(&Right::new(
1326            Resource::Site,
1327            Some("acme/blog".into()),
1328            Action::Deploy
1329        )));
1330        assert!(rights.allows(&Right::new(
1331            Resource::Project,
1332            Some("acme".into()),
1333            Action::Deploy
1334        )));
1335        assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1336        // Admin of the project entity (membership/roles) is reserved for project-admin.
1337        assert!(!rights.allows(&Right::new(
1338            Resource::Project,
1339            Some("acme".into()),
1340            Action::Admin
1341        )));
1342    }
1343
1344    #[test]
1345    fn required_maps_project_paths() {
1346        // A site within a project.
1347        assert_eq!(
1348            Right::required("POST", "/api/projects/acme/sites/blog/deployments"),
1349            Some(Right::new(
1350                Resource::Site,
1351                Some("acme/blog".into()),
1352                Action::Deploy
1353            ))
1354        );
1355        // A project-owned resource (a function): read vs mutate.
1356        assert_eq!(
1357            Right::required("GET", "/api/projects/acme/functions/resize"),
1358            Some(Right::new(
1359                Resource::Project,
1360                Some("acme".into()),
1361                Action::Read
1362            ))
1363        );
1364        assert_eq!(
1365            Right::required("POST", "/api/projects/acme/functions/resize/versions"),
1366            Some(Right::new(
1367                Resource::Project,
1368                Some("acme".into()),
1369                Action::Deploy
1370            ))
1371        );
1372        // The project entity itself.
1373        assert_eq!(
1374            Right::required("DELETE", "/api/projects/acme"),
1375            Some(Right::new(
1376                Resource::Project,
1377                Some("acme".into()),
1378                Action::Admin
1379            ))
1380        );
1381        // Listing/creating projects is node-level.
1382        assert_eq!(
1383            Right::required("GET", "/api/projects"),
1384            Some(Right::new(Resource::System, None, Action::Read))
1385        );
1386        assert_eq!(
1387            Right::required("POST", "/api/projects"),
1388            Some(Right::new(Resource::System, None, Action::Admin))
1389        );
1390        // Legacy top-level functions map to the default project now.
1391        assert_eq!(
1392            Right::required("GET", "/api/functions"),
1393            Some(Right::new(
1394                Resource::Project,
1395                Some("default".into()),
1396                Action::Read
1397            ))
1398        );
1399    }
1400}