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