Skip to main content

rust_web_server/authz/
layer.rs

1//! [`AuthzLayer`]: a route allows a request when its caller has a relation
2//! to an object named from the request.
3
4use std::sync::Arc;
5
6use super::{Authorizer, Identity, ObjectRef};
7use crate::application::Application;
8use crate::middleware::Middleware;
9use crate::request::Request;
10use crate::response::Response;
11use crate::server::ConnectionInfo;
12
13/// An object name built from the request: literal text with `{path:N}`
14/// (the Nth path segment, from 1, percent-decoded), `{query:name}` and
15/// `{header:Name}`. `doc:{path:2}` on `/docs/42/edit` is `doc:42`. Query
16/// parameters and headers come from the client as sent — use a header only
17/// when something in front (forward auth's `copy_header`) sets it.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ObjectTemplate {
20    parts: Vec<Part>,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24enum Part {
25    Literal(String),
26    Path(usize),
27    Query(String),
28    Header(String),
29}
30
31impl ObjectTemplate {
32    pub fn parse(text: &str) -> Result<ObjectTemplate, String> {
33        let mut parts = Vec::new();
34        let mut rest = text;
35        while let Some(open) = rest.find('{') {
36            if open > 0 {
37                parts.push(Part::Literal(rest[..open].to_string()));
38            }
39            let close = rest[open..].find('}').ok_or_else(|| format!("object {text:?}: '{{' without '}}'"))? + open;
40            let inner = &rest[open + 1..close];
41            parts.push(match inner.split_once(':') {
42                Some(("path", n)) => match n.parse::<usize>() {
43                    Ok(n) if n >= 1 => Part::Path(n),
44                    _ => return Err(format!("object {text:?}: {{path:N}} needs N ≥ 1")),
45                },
46                Some(("query", name)) if !name.is_empty() => Part::Query(name.to_string()),
47                Some(("header", name)) if !name.is_empty() => Part::Header(name.to_string()),
48                _ => return Err(format!("object {text:?}: unknown placeholder {{{inner}}} (use path:N, query:name or header:Name)")),
49            });
50            rest = &rest[close + 1..];
51        }
52        if !rest.is_empty() {
53            parts.push(Part::Literal(rest.to_string()));
54        }
55        if rest.contains('}') || parts.is_empty() {
56            return Err(format!("object {text:?}: stray '}}' or empty"));
57        }
58        let template = ObjectTemplate { parts };
59        if template.object_type().is_none() {
60            return Err(format!("object {text:?}: must start with a literal type, e.g. doc:{{path:2}}"));
61        }
62        Ok(template)
63    }
64
65    /// The literal type before the first `:`.
66    pub fn object_type(&self) -> Option<&str> {
67        match self.parts.first() {
68            Some(Part::Literal(text)) => text.split_once(':').map(|(kind, _)| kind).filter(|k| !k.is_empty()),
69            _ => None,
70        }
71    }
72
73    /// The object for `request`; `None` when a placeholder has no value or
74    /// the result isn't a valid object name.
75    ///
76    /// Path segments are percent-decoded once. A path with a `.` or `..`
77    /// segment (raw or encoded), or a segment that decodes to something with
78    /// a `/`, names nothing: a proxy passes the path on as it came, and a
79    /// backend that resolves `/docs/42/../7` would serve doc 7 after a check
80    /// on doc 42.
81    pub fn render(&self, request: &Request) -> Option<ObjectRef> {
82        let raw = crate::url::URL::request_target_path(&request.request_uri).unwrap_or("/");
83        let mut segments = Vec::new();
84        for segment in raw.split('/').filter(|s| !s.is_empty()) {
85            let decoded = crate::app::controller::static_resource::percent_decode_path(segment).ok()?.into_owned();
86            if decoded == "." || decoded == ".." || decoded.contains('/') || decoded.contains('\\') {
87                return None;
88            }
89            segments.push(decoded);
90        }
91        let mut out = String::new();
92        for part in &self.parts {
93            match part {
94                Part::Literal(text) => out.push_str(text),
95                Part::Path(n) => out.push_str(segments.get(n - 1)?),
96                Part::Query(name) => {
97                    let query = request.request_uri.split_once('?')?.1;
98                    let value = query.split('&').find_map(|pair| pair.split_once('=').filter(|(k, _)| k == name).map(|(_, v)| v))?;
99                    if value.is_empty() {
100                        return None;
101                    }
102                    out.push_str(value);
103                }
104                Part::Header(name) => out.push_str(request.header(name).map(|h| h.value.trim()).filter(|v| !v.is_empty())?),
105            }
106        }
107        ObjectRef::parse(&out).ok()
108    }
109}
110
111type Pick = Arc<dyn Fn(&Request) -> Option<(String, ObjectRef)> + Send + Sync>;
112
113/// Middleware that lets a request through when its caller
114/// ([`Identity::current`], set by an authenticator wrapped before — outside —
115/// this layer) has
116/// a relation to an object: `401` without a caller, `403` when the answer
117/// is no, the object can't be named, or the check fails (logged).
118///
119/// ```rust,no_run
120/// use rust_web_server::app::App;
121/// use rust_web_server::authz::{AuthzLayer, Authorizer, MemoryStore, Schema};
122/// use rust_web_server::core::New;
123///
124/// let schema = Schema::new().relation("doc", "viewer", "user").unwrap();
125/// let authz = Authorizer::new(schema, MemoryStore::new()).unwrap();
126/// // The first layer wrapped is the outermost: wrap the authenticator first —
127/// // e.g. `.wrap(JwtLayer::new(secret))` (the `auth` feature) — so it has set
128/// // the caller by the time this layer checks.
129/// let app = App::new().wrap(AuthzLayer::new(authz, "viewer", "doc:{path:2}").unwrap()); // /docs/42 → doc:42
130/// ```
131#[derive(Clone)]
132pub struct AuthzLayer {
133    authz: Authorizer,
134    pick: Pick,
135}
136
137impl std::fmt::Debug for AuthzLayer {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("AuthzLayer").finish_non_exhaustive()
140    }
141}
142
143impl AuthzLayer {
144    /// Require `relation` on the object `template` names (see
145    /// [`ObjectTemplate`]); the relation must exist on the template's type.
146    pub fn new(authz: Authorizer, relation: &str, template: &str) -> Result<AuthzLayer, String> {
147        let template = ObjectTemplate::parse(template)?;
148        let kind = template.object_type().unwrap_or_default().to_string();
149        if !authz.schema().has_relation(&kind, relation) {
150            return Err(format!("{kind}#{relation} isn't defined in the schema"));
151        }
152        let relation = relation.to_string();
153        Ok(AuthzLayer { authz, pick: Arc::new(move |request| Some((relation.clone(), template.render(request)?))) })
154    }
155
156    /// Decide per request which relation on which object is required;
157    /// `None` denies.
158    pub fn with(authz: Authorizer, pick: impl Fn(&Request) -> Option<(String, ObjectRef)> + Send + Sync + 'static) -> AuthzLayer {
159        AuthzLayer { authz, pick: Arc::new(pick) }
160    }
161}
162
163impl Middleware for AuthzLayer {
164    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
165        let Some(caller) = Identity::current() else {
166            return Ok(denied(crate::response::STATUS_CODE_REASON_PHRASE.n401_unauthorized, "authentication required"));
167        };
168        let Some((relation, object)) = (self.pick)(request) else {
169            return Ok(denied(crate::response::STATUS_CODE_REASON_PHRASE.n403_forbidden, "forbidden"));
170        };
171        match self.authz.check_subject(&caller.as_subject(), &relation, &object) {
172            Ok(true) => next.execute(request, connection),
173            Ok(false) => Ok(denied(crate::response::STATUS_CODE_REASON_PHRASE.n403_forbidden, "forbidden")),
174            Err(e) => {
175                eprintln!("authz: {} {relation} {object}: {e}; denied", caller.subject());
176                Ok(denied(crate::response::STATUS_CODE_REASON_PHRASE.n403_forbidden, "forbidden"))
177            }
178        }
179    }
180
181    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
182        true
183    }
184}
185
186fn denied(status: &crate::response::StatusCodeReasonPhrase, message: &str) -> Response {
187    Response::json(status, format!(r#"{{"error":"{message}"}}"#).into_bytes())
188}