Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 82 additions & 7 deletions crates/aisix-core/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,46 @@
use crate::resource::{Resource, ResourceEntry};
use arc_swap::ArcSwap;
use dashmap::DashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;

/// Per-kind table with primary id-index and secondary name-index.
///
/// Both indices point at the same `Arc<ResourceEntry<T>>` so there is no
/// duplicate storage — the name map just holds ids.
#[derive(Debug, Clone)]
#[derive(Debug)]
pub struct ResourceTable<T: Resource> {
by_id: DashMap<String, Arc<ResourceEntry<T>>>,
by_name: DashMap<String, String>,
/// Cached entry count, maintained by [`ResourceTable::insert`] /
/// [`ResourceTable::remove`]. DashMap's own `len()` / `is_empty()`
/// visit every shard (a CAS pair per shard), so per-request
/// emptiness checks on the hot path go through this counter
/// instead — one relaxed load, O(1) regardless of shard count.
count: AtomicUsize,
}

/// Manual impl: `AtomicUsize` is not `Clone`. The count is re-seeded
/// from the cloned map's length, which the etcd watch supervisor's
/// clone-then-mutate update cycle relies on being exact.
impl<T: Resource> Clone for ResourceTable<T> {
fn clone(&self) -> Self {
let by_id = self.by_id.clone();
let count = AtomicUsize::new(by_id.len());
Self {
by_id,
by_name: self.by_name.clone(),
count,
}
}
}

impl<T: Resource> Default for ResourceTable<T> {
fn default() -> Self {
Self {
by_id: DashMap::new(),
by_name: DashMap::new(),
count: AtomicUsize::new(0),
}
}
}
Expand All @@ -46,11 +68,11 @@ impl<T: Resource> ResourceTable<T> {
}

pub fn len(&self) -> usize {
self.by_id.len()
self.count.load(Ordering::Relaxed)
}

pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
self.len() == 0
}

/// Insert or replace an entry, updating both indices.
Expand All @@ -70,12 +92,21 @@ impl<T: Resource> ResourceTable<T> {
}

self.by_name.insert(name, id.clone());
self.by_id.insert(id, Arc::new(entry));
// Provisional increment BEFORE the map insert, corrected after a
// replace. Orders the count so it can only ever read high during
// a mutation window, never low: the empty fast paths may take
// one redundant full scan, but can never skip an entry that is
// already visible in the map.
self.count.fetch_add(1, Ordering::Relaxed);
if self.by_id.insert(id, Arc::new(entry)).is_some() {
self.count.fetch_sub(1, Ordering::Relaxed);
}
}

/// Remove by id; also removes the matching name index entry.
pub fn remove(&self, id: &str) -> Option<Arc<ResourceEntry<T>>> {
let (_, entry) = self.by_id.remove(id)?;
self.count.fetch_sub(1, Ordering::Relaxed);
let name = entry.value.name().to_string();
self.by_name.remove_if(&name, |_, v| v == id);
Some(entry)
Expand Down Expand Up @@ -104,8 +135,13 @@ impl<T: Resource> ResourceTable<T> {
}

/// Snapshot of all entries. Callers get owned `Arc` clones, so iteration
/// does not hold DashMap shards.
/// does not hold DashMap shards. O(1) when the table is empty — the
/// per-request callers (exporter fan-out, policy scans) skip the
/// all-shards walk on unconfigured deployments.
pub fn entries(&self) -> Vec<Arc<ResourceEntry<T>>> {
if self.is_empty() {
return Vec::new();
}
self.by_id.iter().map(|kv| kv.value().clone()).collect()
}

Expand All @@ -115,7 +151,7 @@ impl<T: Resource> ResourceTable<T> {
/// guard is held during the scan, so `pred` must not call back into
/// this table.
pub fn any(&self, pred: impl Fn(&ResourceEntry<T>) -> bool) -> bool {
self.by_id.iter().any(|kv| pred(kv.value()))
!self.is_empty() && self.by_id.iter().any(|kv| pred(kv.value()))
}

/// The single entry satisfying `pred`, without materialising the
Expand All @@ -128,6 +164,9 @@ impl<T: Resource> ResourceTable<T> {
&self,
pred: impl Fn(&ResourceEntry<T>) -> bool,
) -> (Option<Arc<ResourceEntry<T>>>, bool) {
if self.is_empty() {
return (None, false);
}
let mut found: Option<Arc<ResourceEntry<T>>> = None;
for kv in self.by_id.iter() {
if !pred(kv.value()) {
Expand Down Expand Up @@ -290,6 +329,42 @@ mod tests {
assert!(t.get_by_name("alpha").is_none());
}

/// The cached count must stay exact through every mutation shape:
/// fresh insert, same-id replace, remove, remove-miss, and clone.
#[test]
fn cached_count_tracks_all_mutations() {
let t = ResourceTable::<Item>::new();
assert_eq!(t.len(), 0);
assert!(t.is_empty());

t.insert(entry("a-1", "alpha"));
t.insert(entry("b-2", "beta"));
assert_eq!(t.len(), 2);
assert!(!t.is_empty());

// Same-id replace (update, incl. rename) must not double-count.
t.insert(entry("a-1", "aleph"));
assert_eq!(t.len(), 2);

// Remove-miss must not decrement.
assert!(t.remove("missing").is_none());
assert_eq!(t.len(), 2);

assert!(t.remove("a-1").is_some());
assert_eq!(t.len(), 1);

// Clone re-seeds the counter from the cloned map.
let c = t.clone();
assert_eq!(c.len(), 1);
c.insert(entry("c-3", "gamma"));
assert_eq!(c.len(), 2);
assert_eq!(t.len(), 1); // original untouched

assert!(t.remove("b-2").is_some());
assert_eq!(t.len(), 0);
assert!(t.is_empty());
}

#[test]
fn snapshot_handle_atomic_swap() {
let handle: SnapshotHandle<u64> = SnapshotHandle::new(0);
Expand Down
61 changes: 57 additions & 4 deletions crates/aisix-core/src/wildcard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,23 @@ pub fn wildcard_capture(pattern: &str, candidate: &str) -> Option<String> {

/// Whether `pattern` matches `candidate`. A pattern with a single `*` is
/// glob-matched; any other pattern matches only an exactly-equal candidate.
///
/// Same decision as `wildcard_capture(..).is_some()` but without
/// materialising the capture — this runs on the per-request authz path,
/// where a bare `"*"` allowlist entry would otherwise copy the whole
/// candidate just to discard it.
pub fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
if pattern.contains('*') {
wildcard_capture(pattern, candidate).is_some()
} else {
pattern == candidate
let Some(star) = pattern.find('*') else {
return pattern == candidate;
};
if pattern[star + 1..].contains('*') {
return false; // only a single '*' is supported
}
let prefix = &pattern[..star];
let suffix = &pattern[star + 1..];
candidate.len() >= prefix.len() + suffix.len()
&& candidate.starts_with(prefix)
&& candidate.ends_with(suffix)
}

#[cfg(test)]
Expand Down Expand Up @@ -82,6 +93,48 @@ mod tests {
assert_eq!(wildcard_capture("a/*/*", "a/b/c"), None);
}

/// Differential oracle: `wildcard_matches` must agree with
/// `wildcard_capture(..).is_some()` for every pattern shape — the
/// two are duplicate decision procedures guarding the authz
/// allowlists, and a future edit to either must not let them
/// diverge silently.
#[test]
fn matches_agrees_with_capture_oracle() {
let pats = [
"*",
"openai/*",
"*-sfx",
"gpt-*-preview",
"aa*aa",
"a/*/*",
"**",
"lit",
"",
];
let cands = [
"",
"openai/",
"openai/gpt-4o",
"aaa",
"aaaa",
"gpt-4o-preview",
"gpt-4o-final",
"a/b/c",
"-sfx",
"lit",
];
for p in pats {
for c in cands {
let expected = if p.contains('*') {
wildcard_capture(p, c).is_some()
} else {
p == c
};
assert_eq!(wildcard_matches(p, c), expected, "p={p:?} c={c:?}");
}
}
}

#[test]
fn matches_handles_literals_and_globs() {
assert!(wildcard_matches("*", "anything"));
Expand Down
29 changes: 28 additions & 1 deletion crates/aisix-guardrails/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1174,8 +1174,19 @@ impl LiveGuardrailIndex {
/// Cheap on the cache-hit path (one lock acquire + version compare +
/// arc clone + `O(n)` linear walk over attachment rows). Rebuilds only
/// on snapshot version change.
///
/// An empty index (no guardrails configured — the default) resolves
/// to the same empty chain for every request, so that case returns
/// early: no resolve walk, no applied-set copy, no sink attach (a
/// chain with no members never reports to the sink). This is the one
/// chokepoint every endpoint family resolves through, so the fast
/// path covers them all.
pub fn resolve(&self, ctx: &RequestContext<'_>) -> GuardrailChain {
self.current()
let index = self.current();
if index.is_empty() {
return GuardrailChain::empty();
}
index
.resolve(ctx)
.with_metrics_sink(self.metrics_sink.clone())
}
Expand Down Expand Up @@ -2174,6 +2185,22 @@ mod tests {
assert!(!live.is_empty());
}

#[tokio::test]
async fn live_index_empty_fast_path_resolves_empty_chain() {
// Zero-config fast path: an empty index resolves to an empty
// chain with an empty applied set — the same observable shape
// the full resolve walk produces on an empty index.
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
api_key_id: "k",
team_id: None,
});
assert!(chain.is_empty());
assert!(chain.applied().is_empty());
assert!(!chain.check_input(&req("anything")).await.is_block());
}

// -----------------------------------------------------------------------
// per-execution metrics sink (AISIX-Cloud#1076)
// -----------------------------------------------------------------------
Expand Down
50 changes: 41 additions & 9 deletions crates/aisix-proxy/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,39 @@ pub struct JwtIdentity {
/// metric answers "how many" but not "who, when, against what" — so the
/// denial log line, which an operator turns on precisely when investigating,
/// carries the identifying detail instead.
#[derive(Clone, Copy, Default)]
#[derive(Clone, Copy)]
pub(crate) struct DenialContext<'a> {
pub method: &'a str,
pub path: &'a str,
pub request_id: &'a str,
pub source_ip: &'a str,
pub source_ip: LazySourceIp<'a>,
}

/// Source IP for denial logs, resolved only when a denial actually logs
/// it. The successful-auth path has no consumer for it, so it never pays
/// the forwarded-header scan + IP formatting — `ClientContext` resolves
/// the same value once for the handlers instead.
#[derive(Clone, Copy)]
pub(crate) enum LazySourceIp<'a> {
/// Resolve from the request parts + real-ip config on first use.
Deferred(
&'a axum::http::request::Parts,
&'a crate::client_ip::ResolvedRealIp,
),
/// Already resolved by the caller (WebSocket subprotocol auth, which
/// has a `ClientContext` in hand).
Ready(&'a str),
}

impl LazySourceIp<'_> {
pub(crate) fn resolve(&self) -> std::borrow::Cow<'_, str> {
match self {
Self::Deferred(parts, cfg) => {
std::borrow::Cow::Owned(crate::client_ip::source_ip_from_parts(parts, cfg))
}
Self::Ready(s) => std::borrow::Cow::Borrowed(s),
}
}
}

impl AuthenticatedKey {
Expand All @@ -72,7 +99,6 @@ where

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let proxy_state = ProxyState::from_ref(state);
let source_ip = crate::client_ip::source_ip_from_parts(parts, &proxy_state.real_ip);
let request_id = parts
.extensions
.get::<crate::request_id::RequestId>()
Expand All @@ -82,7 +108,8 @@ where
method: parts.method.as_str(),
path: parts.uri.path(),
request_id,
source_ip: &source_ip,
// Deferred: only a denial resolves the source IP.
source_ip: LazySourceIp::Deferred(&*parts, proxy_state.real_ip.as_ref()),
};
let token = match extract_bearer(parts) {
Ok(t) => t,
Expand All @@ -100,7 +127,7 @@ where
http_method = %ctx.method,
path = %ctx.path,
request_id = %ctx.request_id,
source_ip = %ctx.source_ip,
source_ip = %ctx.source_ip.resolve(),
"rejected inbound request without a credential",
);
return Err(e);
Expand Down Expand Up @@ -137,7 +164,10 @@ pub(crate) async fn authenticate_token(
ctx: DenialContext<'_>,
) -> Result<AuthenticatedKey, ProxyError> {
let snapshot = state.snapshot.load();
if crate::jwt::looks_like_jwt(token) && crate::jwt::any_enabled_provider(&snapshot) {
// Provider gate first: it is O(1) when no trust provider is
// configured, so key-only deployments never pay the structural JWT
// probe (base64 + JSON decode of the header segment).
if crate::jwt::any_enabled_provider(&snapshot) && crate::jwt::looks_like_jwt(token) {
return crate::jwt::authenticate_jwt(state, &snapshot, token, ctx).await;
// No enabled trust provider: fall through to the key path so a
// custom-imported key that happens to look like a JWT keeps
Expand Down Expand Up @@ -172,7 +202,9 @@ pub(crate) async fn authenticate_token(
ProxyError::ApiKeyDisabled,
));
}
if entry.value.is_expired_at(chrono::Utc::now()) {
// Read the wall clock only for keys that actually carry a deadline;
// enforcement is unchanged (`is_expired_at` is false for `None`).
if entry.value.expires_at.is_some() && entry.value.is_expired_at(chrono::Utc::now()) {
return Err(deny_key(
state,
"key_expired",
Expand Down Expand Up @@ -217,7 +249,7 @@ fn deny_key(
http_method = %ctx.method,
path = %ctx.path,
request_id = %ctx.request_id,
source_ip = %ctx.source_ip,
source_ip = %ctx.source_ip.resolve(),
"rejected inbound credential",
);
} else {
Expand All @@ -229,7 +261,7 @@ fn deny_key(
http_method = %ctx.method,
path = %ctx.path,
request_id = %ctx.request_id,
source_ip = %ctx.source_ip,
source_ip = %ctx.source_ip.resolve(),
"rejected inbound credential",
);
}
Expand Down
Loading