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