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}