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