Skip to main content

featherbit/admin/
policies.rs

1//! Admin API endpoints for policy CRUD, plus catalogs of available plugin
2//! types and on-disk scripts. Policy mutations rewrite the in-memory gateway
3//! config and trigger validation + graph recompilation via `SharedState::reload`.
4
5use std::sync::Arc;
6
7use axum::extract::{Path, State};
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::routing::get;
11use axum::{Json, Router};
12
13use crate::config::PolicyConfig;
14use crate::state::SharedState;
15
16/// Builds the router for `/api/policies`, `/api/plugins`, and `/api/scripts`.
17pub fn router() -> Router<Arc<SharedState>> {
18    Router::new()
19        .route("/api/policies", get(list_policies))
20        .route(
21            "/api/policies/{name}",
22            get(get_policy).put(update_policy).delete(delete_policy),
23        )
24        .route("/api/plugins", get(list_plugin_types))
25        .route("/api/scripts", get(list_scripts))
26}
27
28/// `GET /api/policies` — returns all configured policies as a JSON array.
29async fn list_policies(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
30    let gw = state.gateway.read().await;
31    Json(&gw.policies).into_response()
32}
33
34/// `GET /api/policies/{name}` — returns the named policy as JSON.
35///
36/// Errors: `404 Not Found` if no policy with that name exists.
37async fn get_policy(
38    State(state): State<Arc<SharedState>>,
39    Path(name): Path<String>,
40) -> impl IntoResponse {
41    let gw = state.gateway.read().await;
42    match gw.policies.iter().find(|p| p.name == name) {
43        Some(policy) => Json(policy).into_response(),
44        None => (
45            StatusCode::NOT_FOUND,
46            Json(serde_json::json!({"error": "not_found"})),
47        )
48            .into_response(),
49    }
50}
51
52/// `PUT /api/policies/{name}` — upserts a policy from the JSON body (the
53/// path name overrides any name in the body), then revalidates and
54/// recompiles all route graphs. Returns `{"status": "updated"}` on success.
55///
56/// Errors: `400 Bad Request` if the resulting configuration fails
57/// validation/recompilation (the previous compiled routes stay active).
58async fn update_policy(
59    State(state): State<Arc<SharedState>>,
60    Path(name): Path<String>,
61    Json(mut policy): Json<PolicyConfig>,
62) -> impl IntoResponse {
63    policy.name = name.clone();
64    let candidate = {
65        let gw = state.gateway.read().await;
66        let mut candidate = gw.clone();
67        if let Some(existing) = candidate.policies.iter_mut().find(|p| p.name == name) {
68            *existing = policy;
69        } else {
70            candidate.policies.push(policy);
71        }
72        candidate
73    };
74
75    match state.config_store.clone().commit(&state, candidate).await {
76        Ok(_) => Json(serde_json::json!({"status": "updated"})).into_response(),
77        Err(e) => (
78            StatusCode::BAD_REQUEST,
79            Json(serde_json::json!({"error": e})),
80        )
81            .into_response(),
82    }
83}
84
85/// `DELETE /api/policies/{name}` — removes the named policy, then
86/// revalidates and recompiles. Returns `{"status": "deleted"}` on success.
87///
88/// Errors: `404 Not Found` if the policy does not exist; `400 Bad Request`
89/// if recompilation fails (e.g. a route still references the policy).
90async fn delete_policy(
91    State(state): State<Arc<SharedState>>,
92    Path(name): Path<String>,
93) -> impl IntoResponse {
94    let candidate = {
95        let gw = state.gateway.read().await;
96        let mut candidate = gw.clone();
97        let before = candidate.policies.len();
98        candidate.policies.retain(|p| p.name != name);
99        if candidate.policies.len() == before {
100            return (
101                StatusCode::NOT_FOUND,
102                Json(serde_json::json!({"error": "not_found"})),
103            )
104                .into_response();
105        }
106        candidate
107    };
108
109    match state.config_store.clone().commit(&state, candidate).await {
110        Ok(_) => Json(serde_json::json!({"status": "deleted"})).into_response(),
111        Err(e) => (
112            StatusCode::BAD_REQUEST,
113            Json(serde_json::json!({"error": e})),
114        )
115            .into_response(),
116    }
117}
118
119/// `GET /api/scripts` — lists scripted-plugin files found in the `plugins/`
120/// directory next to the config directory (currently `.lua` only).
121///
122/// A missing or unreadable directory yields an empty list rather than an error.
123///
124/// ```json
125/// { "scripts": [ { "name": "my_filter", "file": "plugins/my_filter.lua", "runtime": "lua" } ] }
126/// ```
127async fn list_scripts(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
128    let mut scripts = Vec::new();
129
130    // Scan the config directory for a "plugins" subdirectory
131    let config_path = state
132        .config_path
133        .as_deref()
134        .unwrap_or(std::path::Path::new("config"));
135    let plugins_dir = config_path
136        .parent()
137        .unwrap_or(std::path::Path::new("."))
138        .join("plugins");
139
140    if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
141        for entry in entries.flatten() {
142            let path = entry.path();
143            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
144            let name = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
145            let runtime = match ext {
146                "lua" => "lua",
147                _ => continue,
148            };
149            scripts.push(serde_json::json!({
150                "name": name,
151                "file": path.to_string_lossy(),
152                "runtime": runtime,
153            }));
154        }
155    }
156
157    Json(serde_json::json!({ "scripts": scripts }))
158}
159
160/// `GET /api/plugins` — returns the static catalog of node/plugin types
161/// (type id + human-readable description) that the UI's node-graph editor
162/// offers in its palette. Always `200 OK`.
163///
164/// Every type registered in [`crate::plugins::create_plugin`] appears here, in
165/// the same category order the plugin reference uses. Types the UI has no
166/// config form for still work: `NodeInspector` falls back to a raw-JSON config
167/// editor. Keep this in sync with the factory — `test_catalog_covers_factory`
168/// fails if a registered type is missing.
169async fn list_plugin_types() -> impl IntoResponse {
170    Json(serde_json::json!({ "plugins": plugin_catalog() }))
171}
172
173/// The palette catalog: `(type, description)` for every registered node type.
174fn plugin_catalog() -> Vec<serde_json::Value> {
175    const CATALOG: &[(&str, &str)] = &[
176        // Structural & core proxy
177        ("listener", "Route entry point — receives incoming request"),
178        ("client", "Route exit point — sends response to client"),
179        ("upstream", "Forward to a load-balanced backend pool"),
180        ("proxy-rewrite", "Rewrite path, add/remove headers"),
181        (
182            "response-rewrite",
183            "Rewrite response status, headers, and body",
184        ),
185        (
186            "body-transformer",
187            "Rewrite request/response JSON bodies via templates",
188        ),
189        (
190            "degraphql",
191            "Expose a REST endpoint backed by a GraphQL upstream",
192        ),
193        ("redirect", "HTTP redirect, or force HTTP→HTTPS"),
194        ("echo", "Wrap or replace the response body (demo/testing)"),
195        ("gzip", "Compress the response body with gzip"),
196        ("brotli", "Compress the response body with Brotli"),
197        ("request-id", "Attach a unique request-id header"),
198        (
199            "real-ip",
200            "Recover the client IP from a trusted proxy header",
201        ),
202        // Error handling & mocking
203        ("error-handler", "Custom error responses"),
204        (
205            "error-page",
206            "Replace 404/500/502/503 bodies with configured pages",
207        ),
208        (
209            "exit-transformer",
210            "Remap status and body of gateway-generated exits",
211        ),
212        (
213            "mocking",
214            "Respond with a configured mock instead of proxying",
215        ),
216        // Security & access control
217        ("cors", "CORS header management"),
218        ("csrf", "Double-submit CSRF token validation"),
219        ("ip-restriction", "Allow/deny by IP/CIDR"),
220        ("ua-restriction", "Allow/deny by User-Agent regex"),
221        ("referer-restriction", "Allow/deny by Referer host"),
222        ("uri-blocker", "Block requests matching URI regex rules"),
223        ("request-size-limit", "Reject oversized requests"),
224        (
225            "request-validation",
226            "Validate headers/body against JSON Schema",
227        ),
228        (
229            "data-mask",
230            "Mask sensitive fields in bodies, headers, query",
231        ),
232        // Traffic control
233        ("rate-limit", "Token bucket rate limiting"),
234        ("limit-count", "Fixed-window request-count limiting"),
235        (
236            "limit-conn",
237            "Concurrent-request limiting (acquire/release pair)",
238        ),
239        ("api-breaker", "Circuit breaker on unhealthy upstreams"),
240        ("traffic-split", "Weighted / conditional traffic steering"),
241        ("proxy-mirror", "Fire-and-forget clone to a shadow upstream"),
242        (
243            "proxy-cache",
244            "Cache upstream responses (lookup/store pair)",
245        ),
246        ("fault-injection", "Inject delays and abort responses"),
247        (
248            "workflow",
249            "Ordered rules — reject or rate-limit the first match",
250        ),
251        (
252            "traffic-label",
253            "Tag matching requests with headers and labels",
254        ),
255        // Authentication & consumers
256        ("key-auth", "API key authentication"),
257        ("basic-auth", "HTTP Basic authentication"),
258        ("jwt-auth", "JWT validation"),
259        (
260            "hmac-auth",
261            "HMAC request signing (access key / secret key)",
262        ),
263        ("jwe-decrypt", "Decrypt a JWE token into a forwarded header"),
264        (
265            "multi-auth",
266            "Chain auth plugins — accept the first that succeeds",
267        ),
268        ("ldap-auth", "Authenticate Basic credentials against LDAP"),
269        (
270            "consumer-restriction",
271            "Allow/deny by consumer name or group",
272        ),
273        ("acl", "Allow/deny by consumer group"),
274        (
275            "attach-consumer-label",
276            "Copy consumer labels into upstream headers",
277        ),
278        // External auth & authorization
279        (
280            "forward-auth",
281            "Delegate the decision to an external HTTP service",
282        ),
283        ("opa", "Delegate authorization to Open Policy Agent"),
284        ("authz-casbin", "Embedded Casbin RBAC/ABAC enforcement"),
285        ("authz-keycloak", "Keycloak UMA permission check"),
286        (
287            "authz-casdoor",
288            "Casdoor introspection or interactive OAuth login",
289        ),
290        (
291            "openid-connect",
292            "OIDC bearer validation or interactive login",
293        ),
294        ("cas-auth", "CAS ticket validation or interactive SSO login"),
295        ("wolf-rbac", "Wolf RBAC token check"),
296        ("dingtalk-auth", "DingTalk code/token validation"),
297        ("feishu-auth", "Feishu/Lark code/token validation"),
298        // Serverless & FaaS
299        (
300            "serverless-pre-function",
301            "Run inline Lua before the upstream",
302        ),
303        (
304            "serverless-post-function",
305            "Run inline Lua after the upstream",
306        ),
307        (
308            "oas-validator",
309            "Validate requests against an OpenAPI 3 spec",
310        ),
311        ("aws-lambda", "Invoke an AWS Lambda function"),
312        ("azure-functions", "Invoke an Azure Function"),
313        ("openwhisk", "Invoke an Apache OpenWhisk action"),
314        ("openfunction", "Invoke an OpenFunction function"),
315        // Observability & logging
316        ("logging", "Structured access logging"),
317        ("http-logger", "Ship logs to an HTTP endpoint"),
318        ("tcp-logger", "Ship logs over a raw TCP socket"),
319        ("udp-logger", "Ship logs over a raw UDP socket"),
320        ("syslog", "Ship logs via syslog (RFC 5424)"),
321        ("file-logger", "Append logs to a local file"),
322        (
323            "error-log-logger",
324            "Ship request-level errors to a TCP sink",
325        ),
326        ("elasticsearch-logger", "Bulk-index logs into Elasticsearch"),
327        ("clickhouse-logger", "Insert logs into ClickHouse"),
328        ("loki-logger", "Push logs to Grafana Loki"),
329        ("splunk-hec-logging", "Ship logs to Splunk HEC"),
330        ("datadog", "Emit DogStatsD metrics to the Datadog agent"),
331        ("loggly", "Ship logs to SolarWinds Loggly"),
332        ("google-cloud-logging", "Ship logs to Google Cloud Logging"),
333        ("sls-logger", "Ship logs to Alibaba Cloud SLS"),
334        ("tencent-cloud-cls", "Ship logs to Tencent Cloud CLS"),
335        ("skywalking-logger", "Ship logs to Apache SkyWalking"),
336        ("lago", "Meter requests as Lago billing events"),
337        // Tracing & metrics
338        ("prometheus", "Per-consumer request counters"),
339        ("opentelemetry", "OTLP/HTTP trace export (W3C traceparent)"),
340        ("zipkin", "Zipkin v2 trace export (B3 propagation)"),
341        ("skywalking", "SkyWalking segment export (sw8 propagation)"),
342        // Scripting
343        ("script", "Custom plugin logic written in Lua"),
344    ];
345
346    CATALOG
347        .iter()
348        .map(|(t, d)| serde_json::json!({"type": t, "description": d}))
349        .collect()
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    /// Extracts the node types registered in `create_plugin`'s match arms by
357    /// reading its source. The factory is a `match` on `&str`, so there is no
358    /// runtime list to enumerate -- and calling it for every type would need
359    /// each plugin's required config.
360    fn factory_types() -> Vec<String> {
361        include_str!("../plugins/mod.rs")
362            .lines()
363            .filter_map(|line| {
364                let line = line.trim();
365                let rest = line.strip_prefix('"')?;
366                let (name, tail) = rest.split_once('"')?;
367                tail.trim_start()
368                    .starts_with("=>")
369                    .then(|| name.to_string())
370            })
371            .collect()
372    }
373
374    /// The palette catalog drifting behind the factory is a silent failure: the
375    /// plugin still works in YAML, but the UI simply never offers it. That is
376    /// exactly what happened when the APISIX plugins landed and the catalog
377    /// kept advertising the original 13.
378    #[test]
379    fn test_catalog_covers_factory() {
380        let catalog: Vec<String> = plugin_catalog()
381            .iter()
382            .map(|p| p["type"].as_str().unwrap().to_string())
383            .collect();
384
385        let missing: Vec<_> = factory_types()
386            .iter()
387            .filter(|t| !catalog.contains(t))
388            .cloned()
389            .collect();
390        assert!(
391            missing.is_empty(),
392            "registered plugins missing from the UI catalog: {missing:?}"
393        );
394    }
395
396    /// The reverse drift: a catalog entry the factory does not know would put a
397    /// node in the palette that fails policy compilation the moment it is used.
398    #[test]
399    fn test_catalog_has_no_unknown_types() {
400        let factory = factory_types();
401        let unknown: Vec<_> = plugin_catalog()
402            .iter()
403            .map(|p| p["type"].as_str().unwrap().to_string())
404            .filter(|t| !factory.contains(t))
405            .collect();
406        assert!(
407            unknown.is_empty(),
408            "catalog advertises types create_plugin cannot build: {unknown:?}"
409        );
410    }
411
412    #[test]
413    fn test_catalog_has_no_duplicates() {
414        let mut seen = std::collections::HashSet::new();
415        for p in plugin_catalog() {
416            let t = p["type"].as_str().unwrap().to_string();
417            assert!(seen.insert(t.clone()), "duplicate catalog entry: {t}");
418        }
419    }
420
421    /// Extracts the plugin types that have a visual identity, by reading the
422    /// UI's `pluginMeta` map. Its entries are `type: { color, icon }` lines.
423    fn types_with_an_icon() -> Vec<String> {
424        include_str!("../../ui/src/pluginMeta.tsx")
425            .lines()
426            .filter_map(|line| {
427                let line = line.trim();
428                // Map entries declare both a color and an icon. This also matches
429                // getPluginMeta's fallback `|| { color: ..., icon: Box }`, whose
430                // "key" is not a bare plugin name -- the charset check drops it.
431                if !line.contains("color:") || !line.contains("icon:") {
432                    return None;
433                }
434                let (key, _) = line.split_once(':')?;
435                let key = key.trim().trim_matches('\'');
436                let plausible = !key.is_empty()
437                    && key
438                        .chars()
439                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
440                plausible.then(|| key.to_string())
441            })
442            .collect()
443    }
444
445    /// A plugin with no `pluginMeta` entry still appears in the palette, but
446    /// renders with the neutral fallback cube instead of its own icon and color
447    /// -- a silent cosmetic regression that is easy to ship and hard to notice.
448    #[test]
449    fn test_every_catalog_plugin_has_an_icon() {
450        let with_icon = types_with_an_icon();
451        let missing: Vec<_> = plugin_catalog()
452            .iter()
453            .map(|p| p["type"].as_str().unwrap().to_string())
454            .filter(|t| !with_icon.contains(t))
455            .collect();
456        assert!(
457            missing.is_empty(),
458            "plugins with no icon in ui/src/pluginMeta.tsx (they fall back to the generic cube): {missing:?}"
459        );
460    }
461}