dwara_core/security/
authz.rs

1//! Request authorization (DW-020, feature analysis sections 4.7 and 4.15).
2//!
3//! Authorization answers "is this caller ALLOWED here?" after
4//! authentication (DW-019) has answered "who is this caller?". One
5//! route's rules live in [`crate::config::Authz`]; this module
6//! evaluates them against the request's identity, consumer groups, and
7//! EFFECTIVE client IP.
8//!
9//! # Semantics (frozen)
10//!
11//! - **403 vs 401**: a DENIED authenticated (or anonymous-but-
12//!   IP-gated) request answers 403 "forbidden"; an anonymous request on
13//!   a route whose authorization carries identity rules answers 401
14//!   (authentication is implied by identity rules). The one
15//!   anonymous-permitting case is an `ip_acl`-ONLY [`Authz`] — IP allow
16//!   can grant anonymous access (documented exception; an operator
17//!   writing `ip_acl` and nothing else wants an IP gate, not a login
18//!   wall). `auth_required` on the route still independently forces
19//!   authentication regardless of authorization.
20//! - **Within one Authz, deny wins**: `denied_consumers` /
21//!   `denied_groups` beat `allowed_*` at the same level; in the IP ACL
22//!   the deny list is checked before the allow list.
23//! - **IP ACL against the EFFECTIVE client IP**: the
24//!   `X-Forwarded-For`-resolved client when the direct peer is inside
25//!   `gateway.trusted_proxies` (the DW-009 chain — see
26//!   [`effective_client_ip`]), otherwise the direct peer. A spoofed XFF
27//!   from an untrusted peer never influences the decision.
28//! - **Scopes**: every `required_scopes` entry must appear in the JWT
29//!   `scope` claim, which may be a space-separated string
30//!   (`"read write"`) or an array of strings (flattened by `authn` to
31//!   the space-separated form). API-key/Basic identities carry no
32//!   claims and never satisfy scope rules.
33//! - **Claims**: `required_claims` is exact string equality on the
34//!   stringified claim value; a listed claim absent from the token
35//!   fails the match. Only string- and number-valued claims are
36//!   captured on the identity (`authn` drops bool/null/object
37//!   claims), so such a claim can never satisfy a `required_claims`
38//!   entry. ALL comparisons — consumers, groups, scopes, claims —
39//!   are case-sensitive.
40//!
41//! # Precedence chain (done-when of DW-020)
42//!
43//! Rules attach at five levels with the frozen gateway precedence
44//! consumer > route > service > listener > global. [`authorize`]
45//! resolves the chain through [`AuthzChain`]:
46//!
47//! 1. A **deny at ANY level wins** — a consumer-level deny beats a
48//!    route-level allow, and vice versa. Denials are absolute.
49//! 2. Otherwise the **most specific level with rules governs**: its own
50//!    evaluation is the verdict (allow or deny under its rules), and
51//!    less-specific levels are not consulted. A level without an
52//!    [`Authz`] (or with an empty one) is transparent.
53//!
54//! Live links (#123): every level has a config attachment —
55//! `consumers[].authorization` (applies once authentication identifies
56//! the consumer), `routes[].authorization`, `services[].authorization`,
57//! `listeners[].authorization` (the accepting listener), and the
58//! gateway-level `authorization` (global). Authorization runs on
59//! requests that resolved a route, per the documented request-path
60//! order; unrouted 404s never reach the chain.
61//!
62//! **Dry run (DW-041):** any attachment may set `dry_run: true` — its
63//! denials are then evaluated, logged, and counted by the caller
64//! instead of enforced. The resolver ([`resolve`]) walks PAST a dry
65//! deny and stops only at a live one, so a live deny at ANY level still
66//! enforces: monitor mode observes what a block would reject without
67//! ever making enforcement more permissive.
68//!
69//! # Failure posture
70//!
71//! Authorization failures are logged server-side only (via the
72//! decision's `reason` when warranted); clients receive a generic 403
73//! body with no rule detail (which list matched, which claim was absent
74//! — none of it is their business).
75
76use std::net::IpAddr;
77
78use crate::config::net::{ip_in_net, parse_ip_or_cidr, peer_is_trusted};
79use crate::config::{Authz, IpAcl, IpAclDefault};
80use crate::security::authn::Identity;
81
82/// The outcome of an authorization decision.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum Decision {
85    /// The request is authorized (or no rules applied).
86    Allow,
87    /// The request is rejected. `unauthenticated` selects 401 (with the
88    /// authenticator's challenge) over 403; `reason` is server-side
89    /// detail, never sent to the client.
90    Deny {
91        unauthenticated: bool,
92        reason: &'static str,
93    },
94}
95
96/// Everything one authorization evaluation needs to know about the
97/// request.
98pub struct AuthzContext<'a> {
99    pub identity: Option<&'a Identity>,
100    /// Group memberships of the authenticated consumer, carried on the
101    /// identity (#124): config consumers resolve from the config record,
102    /// store-managed consumers from the store's `consumers.groups` —
103    /// group rules apply to both alike.
104    pub consumer_groups: &'a [String],
105    pub peer_ip: IpAddr,
106    /// The XFF-resolved client IP when the peer is a trusted proxy,
107    /// else the peer (see [`effective_client_ip`]).
108    pub effective_ip: IpAddr,
109    /// The GeoIP database handle when `gateway.geoip` is configured
110    /// (DW-050); `None` = every address is geo-UNKNOWN. Behind a
111    /// reference so evaluation never clones the reader.
112    pub geoip: Option<&'a crate::security::geoip::GeoipDb>,
113}
114
115/// Resolve the EFFECTIVE client IP (DW-009 trusted-proxy chain).
116///
117/// - Peer NOT in `trusted_proxies`: the peer itself (an inbound XFF is
118///   untrusted and ignored — spoofing gains nothing).
119/// - Peer trusted: walk the inbound XFF chain right-to-left; the
120///   rightmost address that is NOT itself a trusted proxy is the
121///   client. If every entry is trusted (or the XFF is absent/unparseable),
122///   the leftmost parseable entry stands in; with nothing parseable the
123///   peer (the closest trusted hop) is used.
124///
125///   Documented weakness: when the WHOLE XFF chain is trusted, the
126///   leftmost entry is CLIENT-SUPPLIED and therefore spoofable by
127///   anything that can speak to a trusted proxy — a request from inside
128///   the trusted space can present an arbitrary leftmost address and
129///   be evaluated against it. There is no more-credible source to
130///   prefer in that shape; treat trusted space as hostile to the
131///   extent you rely on IP ACLs.
132pub fn effective_client_ip(trusted: &[String], peer: IpAddr, xff: Option<&str>) -> IpAddr {
133    if !peer_is_trusted(trusted, peer) {
134        return peer;
135    }
136    let Some(xff) = xff else { return peer };
137    let entries: Vec<Option<IpAddr>> = xff
138        .split(',')
139        .map(|e| e.trim().parse::<IpAddr>().ok())
140        .collect();
141    for ip in entries.iter().rev().flatten() {
142        if !peer_is_trusted(trusted, *ip) {
143            return *ip;
144        }
145    }
146    entries.iter().flatten().copied().next().unwrap_or(peer)
147}
148
149/// Evaluate one GeoIP rule set against the context's effective IP
150/// (DW-050). UNKNOWN (no database, unresolvable address) matches
151/// neither side, by the frozen semantics: deny-lists pass unknowns
152/// (geo blocking must not fail closed on infrastructure addresses),
153/// allow-lists reject them (an allow-list admitting unknowns would
154/// filter nothing).
155fn geoip_decision(rules: &crate::config::GeoipRules, ctx: &AuthzContext<'_>) -> Decision {
156    let (country, asn) = match ctx.geoip {
157        Some(db) => (db.country(ctx.effective_ip), db.asn(ctx.effective_ip)),
158        None => (None, None),
159    };
160    let country_hit = |list: &str| {
161        country
162            .as_ref()
163            .is_some_and(|c| c.eq_ignore_ascii_case(list))
164    };
165    if rules.denied_countries.iter().any(|c| country_hit(c)) {
166        return Decision::Deny {
167            unauthenticated: false,
168            reason: "client country is denied",
169        };
170    }
171    if rules.denied_asns.iter().any(|a| asn == Some(*a)) {
172        return Decision::Deny {
173            unauthenticated: false,
174            reason: "client network (ASN) is denied",
175        };
176    }
177    let country_constrained = !rules.allowed_countries.is_empty();
178    let asn_constrained = !rules.allowed_asns.is_empty();
179    if country_constrained && !rules.allowed_countries.iter().any(|c| country_hit(c)) {
180        return Decision::Deny {
181            unauthenticated: false,
182            reason: "client country is not in the allowed list (or is unknown)",
183        };
184    }
185    if asn_constrained && !rules.allowed_asns.iter().any(|a| asn == Some(*a)) {
186        return Decision::Deny {
187            unauthenticated: false,
188            reason: "client network (ASN) is not in the allowed list (or is unknown)",
189        };
190    }
191    Decision::Allow
192}
193
194/// Evaluate one IP ACL against an IP. Deny list first (a deny match
195/// wins over any allow match), then allow, then the configured default.
196fn ip_acl_decision(acl: &IpAcl, ip: IpAddr) -> Decision {
197    if acl.deny.iter().any(|e| ip_matches(e, ip)) {
198        return Decision::Deny {
199            unauthenticated: false,
200            reason: "ip denied by acl deny list",
201        };
202    }
203    if acl.allow.iter().any(|e| ip_matches(e, ip)) {
204        return Decision::Allow;
205    }
206    match acl.default {
207        IpAclDefault::Allow => Decision::Allow,
208        IpAclDefault::Deny => Decision::Deny {
209            unauthenticated: false,
210            reason: "ip matched neither acl list and the default is deny",
211        },
212    }
213}
214
215fn ip_matches(entry: &str, ip: IpAddr) -> bool {
216    parse_ip_or_cidr(entry).is_some_and(|(net, prefix)| ip_in_net(ip, net, prefix))
217}
218
219/// Whether an [`Authz`] carries any identity-typed rule (consumer,
220/// group, scope, claim). Presence of any of these implies
221/// authentication: an anonymous request is rejected 401.
222fn has_identity_rules(authz: &Authz) -> bool {
223    !authz.allowed_consumers.is_empty()
224        || !authz.denied_consumers.is_empty()
225        || !authz.allowed_groups.is_empty()
226        || !authz.denied_groups.is_empty()
227        || !authz.required_scopes.is_empty()
228        || !authz.required_claims.is_empty()
229}
230
231/// Evaluate ONE [`Authz`] block. `None` means the block imposes nothing
232/// (no rules at all — a transparent level); `Some(Allow)` /
233/// `Some(Deny)` is this level's verdict.
234///
235/// Public for testing the single-level authorization contract.
236pub fn evaluate_one(authz: &Authz, ctx: &AuthzContext<'_>) -> Option<Decision> {
237    if !has_identity_rules(authz) && authz.ip_acl.is_none() && authz.geoip.is_none() {
238        return None;
239    }
240    // IP gate first: a deny-list hit (or closed-default miss) rejects
241    // before any identity consideration; an allow passes the gate and
242    // the identity rules (if any) still apply on top. The GeoIP gate
243    // (DW-050) evaluates the same effective address: deny lists reject
244    // known matches, allow lists admit only known matches (UNKNOWN —
245    // unresolvable address or no database — matches neither side).
246    if let Some(acl) = &authz.ip_acl {
247        if let Decision::Deny { reason, .. } = ip_acl_decision(acl, ctx.effective_ip) {
248            return Some(Decision::Deny {
249                unauthenticated: false,
250                reason,
251            });
252        }
253    }
254    if let Some(rules) = &authz.geoip {
255        if let Decision::Deny { reason, .. } = geoip_decision(rules, ctx) {
256            return Some(Decision::Deny {
257                unauthenticated: false,
258                reason,
259            });
260        }
261    }
262    if !has_identity_rules(authz) {
263        // ip_acl/geoip-only: the anonymous-permitting shapes.
264        return Some(Decision::Allow);
265    }
266    let Some(identity) = ctx.identity else {
267        return Some(Decision::Deny {
268            unauthenticated: true,
269            reason: "authorization rules require an authenticated identity",
270        });
271    };
272    // Consumer rules; deny beats allow at the same level.
273    if authz
274        .denied_consumers
275        .iter()
276        .any(|c| c == &identity.consumer_name)
277    {
278        return Some(Decision::Deny {
279            unauthenticated: false,
280            reason: "consumer is denied",
281        });
282    }
283    if !authz.allowed_consumers.is_empty()
284        && !authz
285            .allowed_consumers
286            .iter()
287            .any(|c| c == &identity.consumer_name)
288    {
289        return Some(Decision::Deny {
290            unauthenticated: false,
291            reason: "consumer is not in the allowed set",
292        });
293    }
294    // Group rules (identity-carried groups: config and store-managed
295    // consumers alike, #124).
296    if authz
297        .denied_groups
298        .iter()
299        .any(|g| ctx.consumer_groups.iter().any(|c| c == g))
300    {
301        return Some(Decision::Deny {
302            unauthenticated: false,
303            reason: "consumer's group is denied",
304        });
305    }
306    if !authz.allowed_groups.is_empty()
307        && !authz
308            .allowed_groups
309            .iter()
310            .any(|g| ctx.consumer_groups.iter().any(|c| c == g))
311    {
312        return Some(Decision::Deny {
313            unauthenticated: false,
314            reason: "consumer is in none of the allowed groups",
315        });
316    }
317    // Scopes: every required scope must appear in the `scope` claim
318    // (space-separated string; arrays were flattened by authn).
319    let granted_scopes: Vec<&str> = identity
320        .claims
321        .get("scope")
322        .map(|s| s.split_whitespace().collect())
323        .unwrap_or_default();
324    if authz
325        .required_scopes
326        .iter()
327        .any(|s| !granted_scopes.contains(&s.as_str()))
328    {
329        return Some(Decision::Deny {
330            unauthenticated: false,
331            reason: "a required scope is absent",
332        });
333    }
334    // Claims: exact string equality on stringified values.
335    if authz
336        .required_claims
337        .iter()
338        .any(|(name, want)| identity.claims.get(name) != Some(want))
339    {
340        return Some(Decision::Deny {
341            unauthenticated: false,
342            reason: "a required claim is absent or mismatched",
343        });
344    }
345    Some(Decision::Allow)
346}
347
348/// The five authorization levels in frozen precedence order (most
349/// specific first). Levels without an [`Authz`] attachment today pass
350/// `None`; the chain structure is the DW-020 done-when — links activate
351/// as their config fields land.
352pub struct AuthzChain<'a> {
353    pub consumer: Option<&'a Authz>,
354    pub route: Option<&'a Authz>,
355    pub service: Option<&'a Authz>,
356    pub listener: Option<&'a Authz>,
357    pub global: Option<&'a Authz>,
358}
359
360/// Which chain level produced an authorization decision (DW-041): the
361/// proxy needs the denying link's own config (its `dry_run` flag) to
362/// decide whether a deny is enforced or only observed.
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum AuthzLevel {
365    Consumer,
366    Route,
367    Service,
368    Listener,
369    Global,
370}
371
372impl AuthzLevel {
373    /// The level's name as it appears in logs and metrics.
374    pub fn as_str(self) -> &'static str {
375        match self {
376            AuthzLevel::Consumer => "consumer",
377            AuthzLevel::Route => "route",
378            AuthzLevel::Service => "service",
379            AuthzLevel::Listener => "listener",
380            AuthzLevel::Global => "global",
381        }
382    }
383}
384
385/// One resolved chain evaluation (DW-041): the enforcement verdict plus,
386/// when a `dry_run` attachment would have denied, the observation the
387/// caller logs and counts instead of enforcing.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct Resolved {
390    /// The verdict to ENFORCE. A `dry_run` block's deny is already
391    /// resolved to an allow here (dry run must never make enforcement
392    /// MORE permissive than the live rules — a LIVE deny at any level
393    /// still wins, so the walk continues past a dry deny and stops only
394    /// at a live one).
395    pub decision: Decision,
396    /// The FIRST (most specific) `dry_run` level that would have denied:
397    /// `(level, the deny it would have enforced)`. The caller logs the
398    /// reason, counts the would-be status, and proceeds.
399    pub would_deny: Option<(AuthzLevel, Decision)>,
400}
401
402impl<'a> AuthzChain<'a> {
403    fn links(&self) -> [(AuthzLevel, Option<&'a Authz>); 5] {
404        [
405            (AuthzLevel::Consumer, self.consumer),
406            (AuthzLevel::Route, self.route),
407            (AuthzLevel::Service, self.service),
408            (AuthzLevel::Listener, self.listener),
409            (AuthzLevel::Global, self.global),
410        ]
411    }
412}
413
414/// Resolve the precedence chain. Merge semantics (documented, frozen):
415///
416/// 1. A deny at ANY level wins (denials are absolute; a consumer-level
417///    deny beats a route-level allow and vice versa).
418/// 2. Otherwise the most specific level WITH rules governs: its verdict
419///    is final and less-specific levels are not consulted.
420///
421/// Dry run (DW-041) refines rule 1 per level: a deny from a `dry_run`
422/// block does not STOP the walk — it is recorded in
423/// [`Resolved::would_deny`] and the walk continues, so a LIVE deny at a
424/// less specific level still enforces. A config with no `dry_run` blocks
425/// resolves exactly as before (every deny is live, the walk stops at the
426/// first one).
427pub fn authorize(chain: &AuthzChain<'_>, ctx: &AuthzContext<'_>) -> Decision {
428    resolve(chain, ctx).decision
429}
430
431/// [`authorize`] with the dry-run observation attached (DW-041): the
432/// enforced verdict plus the would-have-denied attachment for the caller
433/// to log and count. See [`Resolved`] for why a dry deny never masks a
434/// live one.
435pub fn resolve(chain: &AuthzChain<'_>, ctx: &AuthzContext<'_>) -> Resolved {
436    let mut governing: Option<Decision> = None;
437    let mut would_deny: Option<(AuthzLevel, Decision)> = None;
438    for (level, link) in chain.links() {
439        let Some(authz) = link else { continue };
440        match evaluate_one(authz, ctx) {
441            // A LIVE deny anywhere is absolute — enforce it immediately.
442            Some(decision @ Decision::Deny { .. }) if !authz.dry_run => {
443                return Resolved {
444                    decision,
445                    would_deny,
446                };
447            }
448            // A DRY deny is observed (first/most specific wins the
449            // report) and the walk CONTINUES: a live deny at a less
450            // specific level must still enforce.
451            Some(decision @ Decision::Deny { .. }) => {
452                if would_deny.is_none() {
453                    would_deny = Some((level, decision));
454                }
455            }
456            // The first (most specific) deciding level governs an allow.
457            Some(decision) => {
458                if governing.is_none() {
459                    governing = Some(decision);
460                }
461            }
462            None => {}
463        }
464    }
465    Resolved {
466        decision: governing.unwrap_or(Decision::Allow),
467        would_deny,
468    }
469}