diff --git a/README.md b/README.md index b2de328..81b08bd 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ These features are framework-agnostic and available to any adapter. | ⏰ **Scheduled windows** | `asyncio`-native scheduler β€” maintenance windows activate and deactivate automatically | | πŸ”” **Webhooks** | Fire HTTP POST on every state change β€” built-in Slack formatter and custom formatters supported | | 🚦 **Rate limiting** | Per-IP, per-user, per-API-key, or global counters β€” tiered limits, burst allowance, runtime mutation | +| πŸ—οΈ **Shield Server** | Centralised control plane for multi-service architectures β€” SDK clients sync state via SSE with zero per-request latency | +| 🌐 **Multi-service CLI** | `SHIELD_SERVICE` env var scopes every command; `shield services` lists connected services | ### Framework adapters @@ -222,14 +224,36 @@ api-shield is built on the **ASGI** standard. The core (`shield.core`) is comple ## Backends -| Backend | Persistence | Multi-instance | -|---|---|---| -| `MemoryBackend` | No | No | -| `FileBackend` | Yes | No | -| `RedisBackend` | Yes | Yes | +### Embedded mode (single service) + +| Backend | Persistence | Multi-instance | Best for | +|---|---|---|---| +| `MemoryBackend` | No | No | Development, tests | +| `FileBackend` | Yes | No (single process) | Simple single-instance prod | +| `RedisBackend` | Yes | Yes | Load-balanced / multi-worker prod | For rate limiting in multi-worker deployments, use `RedisBackend` β€” counters are atomic and shared across all processes. +### Shield Server mode (multi-service) + +Run a dedicated `ShieldServer` process and connect each service via `ShieldSDK`. State is managed centrally; enforcement happens locally with zero per-request network overhead. + +```python +# Shield Server (centralised β€” runs once) +from shield.server import ShieldServer +shield_app = ShieldServer(backend=MemoryBackend(), auth=("admin", "secret")) + +# Each service (connects to the Shield Server) +from shield.sdk import ShieldSDK +sdk = ShieldSDK(server_url="http://shield-server:9000", app_id="payments-service") +sdk.attach(app) +``` + +| Scenario | Shield Server backend | SDK `rate_limit_backend` | +|---|---|---| +| Multi-service, single replica each | `MemoryBackend` or `FileBackend` | not needed | +| Multi-service, multiple replicas | `RedisBackend` | `RedisBackend` (shared counters) | + --- ## Documentation @@ -242,9 +266,11 @@ Full documentation at **[attakay78.github.io/api-shield](https://attakay78.githu | [Decorators reference](https://attakay78.github.io/api-shield/reference/decorators/) | All decorator options | | [Rate limiting](https://attakay78.github.io/api-shield/tutorial/rate-limiting/) | Per-IP, per-user, tiered limits | | [ShieldEngine reference](https://attakay78.github.io/api-shield/reference/engine/) | Programmatic control | -| [Backends](https://attakay78.github.io/api-shield/tutorial/backends/) | Memory, File, Redis, custom | +| [Backends](https://attakay78.github.io/api-shield/tutorial/backends/) | Memory, File, Redis, Shield Server, custom | | [Admin dashboard](https://attakay78.github.io/api-shield/tutorial/admin-dashboard/) | Mounting ShieldAdmin | | [CLI reference](https://attakay78.github.io/api-shield/reference/cli/) | All CLI commands | +| [Shield Server guide](https://attakay78.github.io/api-shield/guides/shield-server/) | Multi-service centralized control | +| [Distributed deployments](https://attakay78.github.io/api-shield/guides/distributed/) | Multi-instance backend guide | | [Production guide](https://attakay78.github.io/api-shield/guides/production/) | Monitoring & deployment automation | ## License diff --git a/docs/adapters/fastapi.md b/docs/adapters/fastapi.md index a28a637..5a49990 100644 --- a/docs/adapters/fastapi.md +++ b/docs/adapters/fastapi.md @@ -533,6 +533,22 @@ Each example below is a complete, self-contained FastAPI app. Click to expand th Fully self-contained webhook demo: three receivers (generic JSON, Slack-formatted, and a custom payload) are mounted on the same app β€” no external service needed. Change a route state via the CLI or dashboard and watch the events appear at `/webhook-log`. + Webhooks are always registered on the engine that owns state mutations: + + - **Embedded mode** β€” register on the engine before passing it to `ShieldAdmin` + - **Shield Server mode** β€” build the engine explicitly and register on it before passing to `ShieldAdmin`; SDK service apps never fire webhooks + + ```python + # Shield Server mode + from shield.core.engine import ShieldEngine + from shield.core.webhooks import SlackWebhookFormatter + from shield.admin.app import ShieldAdmin + + engine = ShieldEngine(backend=RedisBackend(...)) + engine.add_webhook("https://hooks.slack.com/...", formatter=SlackWebhookFormatter()) + shield_app = ShieldAdmin(engine=engine, auth=("admin", "secret")) + ``` + **Webhook receivers (all `@force_active`):** | Endpoint | Payload format | @@ -615,6 +631,172 @@ Each example below is a complete, self-contained FastAPI app. Click to expand th --- +### Shield Server (single service) + +??? example "Centralized Shield Server + one service via ShieldSDK" + + [:material-github: View on GitHub](https://github.com/Attakay78/api-shield/blob/main/examples/fastapi/shield_server.py){ .md-button } + + Demonstrates the centralized Shield Server architecture: one Shield Server process owns all route state, and one service app connects via `ShieldSDK`. State is enforced locally β€” zero per-request network overhead. + + **Two ASGI apps β€” run each in its own terminal:** + + ```bash + # Shield Server (port 8001) + uv run uvicorn examples.fastapi.shield_server:shield_app --port 8001 --reload + + # Service app (port 8000) + uv run uvicorn examples.fastapi.shield_server:service_app --port 8000 --reload + ``` + + **Then visit:** + + - `http://localhost:8001/` β€” Shield dashboard (`admin` / `secret`) + - `http://localhost:8000/docs` β€” service Swagger UI + + **Expected behavior:** + + | Endpoint | Response | Why | + |---|---|---| + | `GET /health` | 200 always | `@force_active` | + | `GET /api/payments` | 503 `MAINTENANCE_MODE` | starts in maintenance | + | `GET /api/orders` | 200 | active on startup | + | `GET /api/legacy` | 503 `ROUTE_DISABLED` | `@disabled` | + | `GET /api/v1/products` | 200 + deprecation headers | `@deprecated` | + + **SDK authentication options:** + + ```python + # Option 1 β€” Auto-login (recommended): SDK logs in on startup, no token management + sdk = ShieldSDK( + server_url="http://localhost:8001", + app_id="payments-service", + username="admin", + password="secret", # inject from env in production + ) + + # Option 2 β€” Pre-issued token + sdk = ShieldSDK( + server_url="http://localhost:8001", + app_id="payments-service", + token="", + ) + + # Option 3 β€” No auth on the Shield Server + sdk = ShieldSDK(server_url="http://localhost:8001", app_id="payments-service") + ``` + + **CLI β€” always targets the Shield Server:** + + ```bash + shield config set-url http://localhost:8001 + shield login admin # password: secret + shield status + shield enable /api/payments + shield disable /api/orders --reason "hotfix" + shield maintenance /api/payments --reason "DB migration" + shield audit + ``` + + **Full source:** + + ```python title="examples/fastapi/shield_server.py" + --8<-- "examples/fastapi/shield_server.py" + ``` + +--- + +### Shield Server (multi-service) + +??? example "Two independent services sharing one Shield Server" + + [:material-github: View on GitHub](https://github.com/Attakay78/api-shield/blob/main/examples/fastapi/multi_service.py){ .md-button } + + Demonstrates two independent FastAPI services (`payments-service` and `orders-service`) both connecting to the same Shield Server. Each service registers its routes under its own `app_id` namespace so the dashboard service dropdown and CLI `SHIELD_SERVICE` env var can manage them independently or together. + + Each service authenticates using `username`/`password` so the SDK obtains its own long-lived `sdk`-platform token on startup β€” no manual token management required. The Shield Server is configured with separate expiry times for human sessions and service tokens: + + ```python + shield_app = ShieldServer( + backend=MemoryBackend(), + auth=("admin", "secret"), + token_expiry=3600, # dashboard / CLI: 1 hour + sdk_token_expiry=31536000, # SDK services: 1 year + ) + + payments_sdk = ShieldSDK( + server_url="http://shield-server:9000", + app_id="payments-service", + username="admin", + password="secret", # inject from env in production + ) + ``` + + **Three ASGI apps β€” run each in its own terminal:** + + ```bash + # Shield Server (port 8001) + uv run uvicorn examples.fastapi.multi_service:shield_app --port 8001 --reload + + # Payments service (port 8000) + uv run uvicorn examples.fastapi.multi_service:payments_app --port 8000 --reload + + # Orders service (port 8002) + uv run uvicorn examples.fastapi.multi_service:orders_app --port 8002 --reload + ``` + + **Then visit:** + + - `http://localhost:8001/` β€” Shield dashboard (use service dropdown to switch) + - `http://localhost:8000/docs` β€” Payments Swagger UI + - `http://localhost:8002/docs` β€” Orders Swagger UI + + **Expected behavior:** + + | Service | Endpoint | Response | Why | + |---|---|---|---| + | payments | `GET /health` | 200 always | `@force_active` | + | payments | `GET /api/payments` | 503 `MAINTENANCE_MODE` | starts in maintenance | + | payments | `GET /api/refunds` | 200 | active | + | payments | `GET /api/v1/invoices` | 200 + deprecation headers | `@deprecated` | + | orders | `GET /health` | 200 always | `@force_active` | + | orders | `GET /api/orders` | 200 | active | + | orders | `GET /api/shipments` | 503 `ROUTE_DISABLED` | `@disabled` | + | orders | `GET /api/cart` | 200 | active | + + **CLI β€” multi-service workflow:** + + ```bash + shield config set-url http://localhost:8001 + shield login admin # password: secret + shield services # list all connected services + + # Scope to payments via env var + export SHIELD_SERVICE=payments-service + shield status + shield enable /api/payments + shield current-service # confirm active context + + # Switch to orders with explicit flag (overrides env var) + shield status --service orders-service + shield disable /api/cart --reason "redesign" --service orders-service + + # Unscoped β€” operates across all services + unset SHIELD_SERVICE + shield status + shield audit + shield global disable --reason "emergency maintenance" + shield global enable + ``` + + **Full source:** + + ```python title="examples/fastapi/multi_service.py" + --8<-- "examples/fastapi/multi_service.py" + ``` + +--- + ### Custom backend (SQLite) ??? example "Implementing `ShieldBackend` with aiosqlite" diff --git a/docs/changelog.md b/docs/changelog.md index d948ddb..14d7da1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), --- +## [Unreleased] + +### Fixed + +- **Unprotected routes table not updating after adding a policy**: routes stayed visible in the "Unprotected Routes" section after a rate limit was set because the filter compared `METHOD:/path` display values against path-only strings stripped of the method prefix; comparison now uses the full composite key correctly. +- **Invalid rate limit strings accepted silently**: submitting a malformed limit (e.g. `2/minutesedrr`) was stored and caused a 500 on every request; the engine now validates the limit string before persisting and the dashboard modal surfaces an inline error with the correct format hint. + +### Added + +- **`SHIELD_SERVICE` env var fallback on all `--service` CLI options**: `shield status`, `shield enable`, `shield disable`, `shield maintenance`, and `shield schedule` all read `SHIELD_SERVICE` automatically β€” set it once with `export SHIELD_SERVICE=payments-service` and every command scopes itself to that service without repeating `--service`. An explicit `--service` flag always wins. +- **`shield current-service` command**: shows the active service context from the `SHIELD_SERVICE` environment variable, or a hint to set it when the variable is absent. +- **`shield services` command**: lists all distinct service names registered with the Shield Server, so you can discover which services are connected before switching context. +- **Dashboard "Unprotected Routes" section**: the Rate Limits page now surfaces all routes that have no rate limit policy, with an "Add Limit" button per row that opens a modal to configure method, limit, algorithm, and key strategy in-place β€” no CLI required. +- **Route existence validation in `set_rate_limit_policy()`**: attempting to add a rate limit policy for a route that does not exist now raises `RouteNotFoundException` immediately; the REST API returns `404` and the CLI prints a clear error, preventing phantom policies from accumulating. +- **`ShieldSDK.rate_limit_backend` parameter**: pass a `RedisBackend` instance to share rate limit counters across all replicas of a service connected to the same Shield Server; without it each replica enforces limits independently. +- **Rate limit policy SSE propagation to SDK clients**: policies set or deleted via the CLI or dashboard are now broadcast over the Shield Server's SSE stream as typed `rl_policy` envelopes and applied to every connected SDK client in real time β€” no restart required. +- **`ShieldSDK` auto-login (`username` / `password` params)**: pass credentials directly to `ShieldSDK` instead of a pre-issued token; on startup the SDK calls `POST /api/auth/login` with `platform="sdk"` and caches the resulting long-lived token for the life of the process β€” no manual token management required. +- **Separate SDK token lifetime (`sdk_token_expiry`)**: `ShieldServer` and `ShieldAdmin` now accept `sdk_token_expiry` (default 1 year) independently from `token_expiry` (default 24 h for dashboard / CLI users), so service apps can run indefinitely without re-authentication while human sessions remain short-lived. +- **`platform` field on `POST /api/auth/login`**: the login endpoint now accepts `"cli"` (default) or `"sdk"` in the request body; `"sdk"` tokens use `sdk_token_expiry` and are intended for machine-to-machine service authentication. + +--- + ## [0.7.0] ### Added diff --git a/docs/guides/distributed.md b/docs/guides/distributed.md index b29717f..af48829 100644 --- a/docs/guides/distributed.md +++ b/docs/guides/distributed.md @@ -24,7 +24,7 @@ The answer depends entirely on which backend you use. | Dashboard SSE live updates | In-process only | No β€” polling fallback | Yes | | `subscribe()` pub/sub | `asyncio.Queue` | `NotImplementedError` | Redis pub/sub | | `subscribe_global_config()` | `NotImplementedError` | `NotImplementedError` | Redis pub/sub | -| `subscribe_rate_limit_policy()` | `NotImplementedError` | `NotImplementedError` | Redis pub/sub | +| `subscribe_rate_limit_policy()` | `asyncio.Queue` | `NotImplementedError` | Redis pub/sub | | Fail-open on backend error | Always | Always | Always | | Recommended for | Dev / tests | Single-instance prod | Multi-instance prod | @@ -181,7 +181,9 @@ engine.set_rate_limit_policy(...) `ShieldEngine.start()` creates a `shield-rl-policy-listener` background task. Each message on `shield:rl-policy-change` carries the full policy payload (for `set`) or just the key (for `delete`), so the receiving instance applies the delta directly without an extra Redis round-trip. -For `MemoryBackend` and `FileBackend`, `subscribe_rate_limit_policy()` raises `NotImplementedError`. The listener exits silently β€” single-instance deployments need no cross-process sync. +For `FileBackend`, `subscribe_rate_limit_policy()` raises `NotImplementedError`. The listener exits silently β€” `FileBackend` is single-process only, so cross-process sync is unnecessary. + +`MemoryBackend` now implements `subscribe_rate_limit_policy()` via an `asyncio.Queue`, meaning the Shield Server can broadcast RL policy events to all SDK clients connected over SSE β€” see [Rate limit policy SSE propagation](#rate-limit-policy-sse-propagation) below. ### The request lifecycle across instances @@ -255,6 +257,35 @@ If you need fully consistent OpenAPI schemas across instances, you can force a c --- +## Rate limit policy SSE propagation + +When using **Shield Server + ShieldSDK**, rate limit policies set via the CLI (`shield rl set`) or the dashboard are immediately broadcast to all connected SDK clients over the persistent SSE stream β€” no restart required. + +The SSE stream now carries typed envelopes: + +``` +data: {"type": "state", "payload": {...RouteState...}} +data: {"type": "rl_policy", "action": "set", "key": "GET:/api/pay", "policy": {...}} +data: {"type": "rl_policy", "action": "delete", "key": "GET:/api/pay"} +``` + +When `ShieldServerBackend` receives an `rl_policy` event, it updates its local `_rl_policy_cache` and notifies the engine's existing `_run_rl_policy_listener()` background task, which applies the policy change to `engine._rate_limit_policies` immediately. The propagation path is: + +``` +shield rl set GET:/api/pay 100/minute + β†’ Shield Server engine.set_rate_limit_policy() + β†’ MemoryBackend._rl_policy_subscribers (asyncio.Queue fan-out) + β†’ sdk_events SSE stream (typed envelope) + β†’ ShieldServerBackend._listen_sse() (background task) + β†’ _run_rl_policy_listener() (background task) + β†’ engine._rate_limit_policies updated + β†’ next request enforces new limit ← typically < 5 ms end-to-end on a LAN +``` + +Per-request enforcement reads `engine._rate_limit_policies` synchronously β€” no network hop, no lock, zero added latency. + +--- + ## Choosing the right backend ``` diff --git a/docs/guides/production.md b/docs/guides/production.md index db9f3e2..86b69c6 100644 --- a/docs/guides/production.md +++ b/docs/guides/production.md @@ -98,22 +98,57 @@ if __name__ == "__main__": ### Webhook alerting (Slack / PagerDuty) -api-shield fires webhooks on every state change. Wire a Slack webhook to get instant alerts without polling: +api-shield fires webhooks on every state change β€” enable, disable, maintenance on/off. Webhook delivery always originates from the process that owns the engine where state mutations happen. Where you register them depends on your deployment mode. + +#### Embedded mode (single service) + +Register directly on the engine before mounting `ShieldAdmin`: ```python from shield.core.engine import ShieldEngine from shield.core.webhooks import SlackWebhookFormatter +from shield.fastapi import ShieldAdmin engine = ShieldEngine() engine.add_webhook( url=os.environ["SLACK_WEBHOOK_URL"], formatter=SlackWebhookFormatter(), ) +engine.add_webhook(url=os.environ["PAGERDUTY_WEBHOOK_URL"]) + +admin = ShieldAdmin(engine=engine, auth=("admin", os.environ["SHIELD_PASS"])) +app.mount("/shield", admin) +``` -# Or a generic JSON endpoint (e.g. PagerDuty Events API v2) +#### Shield Server mode (multi-service) + +State mutations happen on the **Shield Server**, not on SDK clients. Build the engine explicitly so you can call `add_webhook()` on it before passing it to `ShieldAdmin`: + +```python +# shield_server.py +import os +from shield.core.engine import ShieldEngine +from shield.core.backends.redis import RedisBackend +from shield.core.webhooks import SlackWebhookFormatter +from shield.admin.app import ShieldAdmin + +engine = ShieldEngine(backend=RedisBackend(os.environ["REDIS_URL"])) +engine.add_webhook( + url=os.environ["SLACK_WEBHOOK_URL"], + formatter=SlackWebhookFormatter(), +) engine.add_webhook(url=os.environ["PAGERDUTY_WEBHOOK_URL"]) + +shield_app = ShieldAdmin( + engine=engine, + auth=("admin", os.environ["SHIELD_PASS"]), + secret_key=os.environ["SHIELD_SECRET_KEY"], +) ``` +!!! note + SDK service apps (`ShieldSDK`) never fire webhooks. They only enforce state locally β€” all mutations and therefore all webhook triggers originate on the Shield Server. + Webhook payload sent on every state change: ```json @@ -126,7 +161,7 @@ Webhook payload sent on every state change: } ``` -Webhook failures are non-blocking; they are logged and never affect the request path. +Webhook failures are non-blocking; they are logged and never affect the request path. On multi-node Shield Server deployments (`RedisBackend`), Redis `SET NX` deduplication ensures only one node fires per event. --- diff --git a/docs/guides/shield-server.md b/docs/guides/shield-server.md new file mode 100644 index 0000000..cfb2f34 --- /dev/null +++ b/docs/guides/shield-server.md @@ -0,0 +1,366 @@ +# Shield Server Guide + +The Shield Server is a standalone ASGI application that acts as the centralised control plane for multi-service architectures. Services connect to it via **ShieldSDK**, which enforces rules locally using an in-process cache kept fresh over a persistent SSE connection β€” so there is **zero per-request network overhead**. + +--- + +## When to use Shield Server + +Use the embedded `ShieldAdmin` (single-mount) pattern when you have **one service**. Move to Shield Server when: + +- You run **two or more independent services** and want to manage all their routes from one dashboard and one CLI target. +- You want a **dedicated control plane** that survives individual service restarts. +- You need per-service namespacing so `payments-service:/api/payments` and `orders-service:/api/payments` never collide. + +--- + +## Architecture + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + CLI / Browser β”‚ Shield Server (port 9000) β”‚ + ─────────────→ β”‚ ShieldServer(backend=...) β”‚ + β”‚ β”‚ + β”‚ / dashboard UI β”‚ + β”‚ /api/... REST API (CLI) β”‚ + β”‚ /api/sdk/events SSE stream β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP + SSE + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ payments-app β”‚ β”‚ orders-app β”‚ β”‚ any-other-app β”‚ + β”‚ port 8000 β”‚ β”‚ port 8002 β”‚ β”‚ ... β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ ShieldSDK β”‚ β”‚ ShieldSDK β”‚ β”‚ ShieldSDK β”‚ + β”‚ app_id= β”‚ β”‚ app_id= β”‚ β”‚ app_id= β”‚ + β”‚ "payments-svc" β”‚ β”‚ "orders-svc" β”‚ β”‚ ... β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ local cache β”‚ β”‚ local cache β”‚ β”‚ local cache β”‚ + β”‚ (zero-latency β”‚ β”‚ (zero-latency β”‚ β”‚ (zero-latency β”‚ + β”‚ enforcement) β”‚ β”‚ enforcement) β”‚ β”‚ enforcement) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Key properties:** + +- The Shield Server is the single source of truth for route state. +- SDK clients pull state on startup via `GET /api/routes` and receive live updates via `GET /api/sdk/events` (SSE). +- All enforcement happens locally inside each SDK client β€” the Shield Server is never on the hot path. +- The CLI always points at the Shield Server, never at individual services. + +--- + +## Quick setup + +### 1. Shield Server + +```python title="shield_server_app.py" +from shield.server import ShieldServer +from shield.core.backends.memory import MemoryBackend + +# MemoryBackend is fine for development. +# Use FileBackend for state persistence or RedisBackend for HA. +shield_app = ShieldServer( + backend=MemoryBackend(), + auth=("admin", "secret"), + # secret_key="stable-key-for-persistent-tokens", + # token_expiry=86400, # 24 h +) +``` + +```bash +uvicorn shield_server_app:shield_app --port 9000 +``` + +### 2. Service app + +```python title="payments_app.py" +from fastapi import FastAPI +from shield.sdk import ShieldSDK +from shield.fastapi import ShieldRouter, maintenance, force_active + +sdk = ShieldSDK( + server_url="http://localhost:9000", + app_id="payments-service", + # token="", + reconnect_delay=5.0, +) + +app = FastAPI() +sdk.attach(app) # wires ShieldMiddleware + startup/shutdown hooks + +router = ShieldRouter(engine=sdk.engine) + +@router.get("/health") +@force_active +async def health(): + return {"status": "ok"} + +@router.get("/api/payments") +@maintenance(reason="Payment processor upgrade") +async def get_payments(): + return {"payments": []} + +app.include_router(router) +``` + +```bash +uvicorn payments_app:app --port 8000 +``` + +### 3. CLI + +```bash +shield config set-url http://localhost:9000 +shield login admin # password: secret +shield services # list all connected services +shield status --service payments-service +shield enable /api/payments +``` + +--- + +## `sdk.attach(app)` β€” what it does + +`attach()` wires three things into the FastAPI app: + +1. **`ShieldMiddleware`** β€” checks every request against the local state cache (zero network overhead). +2. **Startup hook** β€” syncs state from the Shield Server, opens the SSE listener, discovers `@shield_meta__`-decorated routes, and registers any new ones with the server. +3. **Shutdown hook** β€” closes the SSE connection and HTTP client cleanly. + +--- + +## Multi-service CLI workflow + +### `SHIELD_SERVICE` env var + +Set once, and every subsequent command (`status`, `enable`, `disable`, `maintenance`, `schedule`) scopes itself to that service automatically: + +```bash +export SHIELD_SERVICE=payments-service +shield status # only payments routes +shield disable GET:/api/payments --reason "hotfix" +shield enable GET:/api/payments +shield current-service # confirm active context +``` + +An explicit `--service` flag always overrides the env var: + +```bash +export SHIELD_SERVICE=payments-service +shield status --service orders-service # acts on orders, ignores env var +``` + +### Unscoped commands + +Clear `SHIELD_SERVICE` to operate across all services at once: + +```bash +unset SHIELD_SERVICE +shield status # routes from all services +shield audit # audit log across all services +shield global disable --reason "emergency maintenance" +shield global enable +``` + +### Useful discovery commands + +```bash +shield services # list all services registered with the Shield Server +shield current-service # show the active SHIELD_SERVICE context +``` + +--- + +## Backend choice for the Shield Server + +The backend affects **only the Shield Server process**. SDK clients always receive live updates via SSE regardless of which backend the Shield Server uses. + +| Scenario | Shield Server backend | Notes | +|---|---|---| +| Development / single developer | `MemoryBackend` | State lost on Shield Server restart | +| Production, single Shield Server | `FileBackend` | State survives restarts; single process only | +| Production, multiple Shield Server nodes (HA) | `RedisBackend` | All nodes share state via pub/sub | + +```python +# Development +shield_app = ShieldServer(backend=MemoryBackend(), auth=("admin", "secret")) + +# Production β€” persistent single node +from shield.core.backends.file import FileBackend +shield_app = ShieldServer(backend=FileBackend("shield-state.json"), auth=("admin", "secret")) + +# Production β€” HA / load-balanced Shield Server +from shield.core.backends.redis import RedisBackend +shield_app = ShieldServer(backend=RedisBackend("redis://redis:6379/0"), auth=("admin", "secret")) +``` + +--- + +## Shared rate limit counters across SDK replicas + +When a service runs multiple replicas (e.g. three `payments-app` pods), each SDK client enforces rate limits independently using its own in-process counters. This means a `100/minute` limit is enforced as 100 per replica β€” effectively `100 Γ— num_replicas` across the fleet. + +To enforce the limit **across all replicas combined**, pass a shared `RedisBackend` as `rate_limit_backend`: + +```python +from shield.core.backends.redis import RedisBackend + +sdk = ShieldSDK( + server_url="http://shield-server:9000", + app_id="payments-service", + rate_limit_backend=RedisBackend(url="redis://redis:6379/1"), +) +``` + +Use a **different Redis database** (or a different Redis instance) from the one used by the Shield Server's backend to avoid key collisions. + +### Full deployment matrix + +| Services | Replicas per service | Shield Server backend | SDK `rate_limit_backend` | +|---|---|---|---| +| 1 | 1 | Use embedded `ShieldAdmin` instead | β€” | +| 2+ | 1 each | `MemoryBackend` or `FileBackend` | not needed | +| 2+ | 2+ each (shared counters) | `RedisBackend` | `RedisBackend` (different DB) | +| 2+ | 2+ each (independent counters per replica) | `RedisBackend` | not needed | + +--- + +## SSE event types + +The Shield Server's `GET /api/sdk/events` stream carries two event types: + +### Route state changes + +``` +data: {"type": "state", "payload": {...RouteState JSON...}} +``` + +Emitted whenever a route is enabled, disabled, put in maintenance, etc. The SDK client updates its local cache and the change is visible on the very next `engine.check()` call. + +### Rate limit policy changes + +``` +data: {"type": "rl_policy", "action": "set", "key": "GET:/api/pay", "policy": {...}} +data: {"type": "rl_policy", "action": "delete", "key": "GET:/api/pay"} +``` + +Emitted whenever `shield rl set` or `shield rl delete` (or the dashboard) changes a rate limit policy. The SDK client applies the change to its local `engine._rate_limit_policies` dict immediately via the existing `_run_rl_policy_listener()` background task. + +Both event types are delivered over the same persistent SSE connection. Propagation latency is the SSE round-trip β€” typically under 5 ms on a LAN. + +--- + +## Authentication + +### No auth (development) + +If `auth` is omitted, the Shield Server is open β€” no credentials required anywhere: + +```python +shield_app = ShieldServer(backend=MemoryBackend()) # no auth= +``` + +```python +sdk = ShieldSDK( + server_url="http://shield-server:9000", + app_id="payments-service", + # no token, username, or password needed +) +``` + +### Dashboard and CLI users + +Use `auth=("admin", "secret")` for a single admin user, or pass a list for multiple users: + +```python +shield_app = ShieldServer( + backend=MemoryBackend(), + auth=[("alice", "pw1"), ("bob", "pw2")], +) +``` + +Human users authenticate via the dashboard login form or `shield login` in the CLI. Their sessions last `token_expiry` seconds (default 24 h). + +### SDK service authentication + +SDK service apps have two options for authenticating with the Shield Server. + +#### Option 1 β€” Auto-login with credentials (recommended) + +Pass `username` and `password` directly to `ShieldSDK`. On startup the SDK calls `POST /api/auth/login` with `platform="sdk"` and obtains a long-lived token automatically β€” no manual token management required: + +```python +import os +from shield.sdk import ShieldSDK + +sdk = ShieldSDK( + server_url=os.environ["SHIELD_SERVER_URL"], + app_id="payments-service", + username=os.environ["SHIELD_USERNAME"], + password=os.environ["SHIELD_PASSWORD"], +) +``` + +Store credentials in your secrets manager or CI/CD environment variables and inject them at deploy time. The token is obtained once on startup and lives for `sdk_token_expiry` seconds (default 1 year) so the service never needs re-authentication. + +#### Option 2 β€” Pre-issued token + +Generate a token once via the CLI and pass it directly: + +```bash +shield config set-url http://shield-server:9000 +shield login admin +# Copy the token from ~/.shield/config.json +``` + +```python +sdk = ShieldSDK( + server_url="http://shield-server:9000", + app_id="payments-service", + token=os.environ["SHIELD_TOKEN"], +) +``` + +### Separate token lifetimes + +`token_expiry` and `sdk_token_expiry` are independent so human sessions can be short while service tokens are long-lived: + +```python +shield_app = ShieldServer( + backend=RedisBackend(os.environ["REDIS_URL"]), + auth=("admin", os.environ["SHIELD_ADMIN_PASSWORD"]), + secret_key=os.environ["SHIELD_SECRET_KEY"], + token_expiry=3600, # dashboard / CLI users: 1 hour + sdk_token_expiry=31536000, # SDK service tokens: 1 year (default) +) +``` + +| Token platform | Issued to | Expiry param | Default | +|---|---|---|---| +| `"dashboard"` | Browser sessions | `token_expiry` | 24 h | +| `"cli"` | `shield login` / CLI operators | `token_expiry` | 24 h | +| `"sdk"` | `ShieldSDK` auto-login | `sdk_token_expiry` | 1 year | + +--- + +## Production checklist + +- [ ] Use a stable `secret_key` on `ShieldServer` so issued tokens survive Shield Server restarts +- [ ] Prefer `username`/`password` on `ShieldSDK` over pre-issued tokens β€” the SDK self-authenticates with a `sdk_token_expiry` token on each startup +- [ ] Set `token_expiry` (human sessions) and `sdk_token_expiry` (service tokens) independently +- [ ] Use `FileBackend` or `RedisBackend` on the Shield Server so route state survives restarts +- [ ] Use `RedisBackend` on the Shield Server if you run more than one Shield Server instance (HA) +- [ ] Pass `rate_limit_backend=RedisBackend(...)` to each SDK if you need shared counters across replicas of the same service +- [ ] Set `reconnect_delay` on `ShieldSDK` to a value appropriate for your network (default 5 s) +- [ ] Exempt health and readiness probe endpoints with `@force_active` +- [ ] Test fail-open behaviour by stopping the Shield Server and verifying that SDK clients continue to serve traffic against their last-known cache + +--- + +## Further reading + +- [**Distributed Deployments β†’**](distributed.md) β€” deep-dive on `MemoryBackend`, `FileBackend`, `RedisBackend` internals and the pub/sub patterns they use +- [**FastAPI adapter examples β†’**](../adapters/fastapi.md#shield-server-single-service) β€” runnable `shield_server.py` and `multi_service.py` examples +- [**CLI reference β†’**](../reference/cli.md) β€” all commands including `shield services` and `shield current-service` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6712a2f..a00be39 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -51,8 +51,53 @@ shield logout --- +## Multi-service commands + +### `shield services` + +List all distinct service names registered with the Shield Server. Use this to discover which services are currently connected before switching context with `SHIELD_SERVICE`. + +```bash +shield services +``` + +--- + +### `shield current-service` + +Show the active service context (the value of the `SHIELD_SERVICE` environment variable). Useful for confirming which service subsequent commands will target. + +```bash +shield current-service +``` + +**When `SHIELD_SERVICE` is set:** + +``` +Active service: payments-service (from SHIELD_SERVICE) +``` + +**When `SHIELD_SERVICE` is not set:** + +``` +No active service set. +Set one with: export SHIELD_SERVICE= +``` + +--- + ## Route commands +Route commands accept an optional `--service` flag to scope to a specific service. All five commands also read the `SHIELD_SERVICE` environment variable as a fallback β€” an explicit `--service` flag always wins. + +```bash +export SHIELD_SERVICE=payments-service # set once +shield status # scoped to payments-service +shield enable GET:/payments # scoped to payments-service +unset SHIELD_SERVICE +shield status --service orders-service # explicit flag, no env var needed +``` + ### `shield status` Show all registered routes and their current state, or inspect a single route in detail. @@ -62,12 +107,14 @@ shield status # all routes, page 1 shield status GET:/payments # one route shield status --page 2 # next page shield status --per-page 50 # 50 rows per page +shield status --service payments-service # scope to one service ``` | Option | Description | |---|---| | `--page INT` | Page number to display when listing all routes (default: 1) | | `--per-page INT` | Rows per page (default: 20) | +| `--service TEXT` | Filter to a specific service. Falls back to `SHIELD_SERVICE` env var. | **Example output:** @@ -90,8 +137,13 @@ Restore a route to `ACTIVE`. Works regardless of the current status. ```bash shield enable GET:/payments +shield enable GET:/payments --service payments-service ``` +| Option | Description | +|---|---| +| `--service TEXT` | Target service. Falls back to `SHIELD_SERVICE` env var. | + --- ### `shield disable` @@ -102,12 +154,14 @@ Permanently disable a route. Returns 503 to all callers. shield disable GET:/payments shield disable GET:/payments --reason "Use /v2/payments instead" shield disable GET:/payments --reason "hotfix" --until 2h +shield disable GET:/payments --service payments-service --reason "hotfix" ``` | Option | Description | |---|---| | `--reason TEXT` | Reason shown in error responses and recorded in the audit log | | `--until DURATION` | Automatically re-enable after this duration. Accepts `2h`, `30m`, `1d`, or an ISO 8601 datetime. | +| `--service TEXT` | Target service. Falls back to `SHIELD_SERVICE` env var. | --- @@ -117,6 +171,7 @@ Put a route in maintenance mode. Optionally schedule automatic activation and de ```bash shield maintenance GET:/payments --reason "DB swap" +shield maintenance GET:/payments --service payments-service --reason "DB swap" ``` ```bash @@ -132,6 +187,7 @@ shield maintenance GET:/payments \ | `--reason TEXT` | Shown in the 503 error response | | `--start DATETIME` | Start of the maintenance window (ISO 8601). Maintenance activates automatically at this time. | | `--end DATETIME` | End of the maintenance window. Sets the `Retry-After` header and restores `ACTIVE` automatically. | +| `--service TEXT` | Target service. Falls back to `SHIELD_SERVICE` env var. | --- @@ -144,6 +200,8 @@ shield schedule GET:/payments \ --start 2025-06-01T02:00Z \ --end 2025-06-01T04:00Z \ --reason "Planned migration" +shield schedule GET:/payments --service payments-service \ + --start 2025-06-01T02:00Z --end 2025-06-01T04:00Z ``` | Option | Description | @@ -151,6 +209,7 @@ shield schedule GET:/payments \ | `--start DATETIME` | When to activate maintenance (ISO 8601, required) | | `--end DATETIME` | When to restore the route to `ACTIVE` (ISO 8601, required) | | `--reason TEXT` | Reason shown in the 503 response during the window | +| `--service TEXT` | Target service. Falls back to `SHIELD_SERVICE` env var. | --- diff --git a/docs/tutorial/backends.md b/docs/tutorial/backends.md index 7c7bfdf..7c5722a 100644 --- a/docs/tutorial/backends.md +++ b/docs/tutorial/backends.md @@ -103,6 +103,104 @@ Best for: multi-instance / load-balanced production deployments. --- +## Shield Server + ShieldSDK (multi-service) + +When you run multiple independent services, a dedicated **Shield Server** acts as the centralised control plane. Each service connects to it via **ShieldSDK**, which keeps an in-process cache synced over a persistent SSE connection β€” so enforcement never touches the network per request. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Shield Server (port 9000) β”‚ +β”‚ ShieldServer(backend=...) β”‚ +β”‚ Dashboard, REST API, SSE β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP + SSE + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ payments-app β”‚ β”‚ orders-app β”‚ +β”‚ ShieldSDK β”‚ β”‚ ShieldSDK β”‚ +β”‚ (local cache)β”‚ β”‚ (local cache)β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Shield Server setup:** + +```python +from shield.server import ShieldServer +from shield.core.backends.memory import MemoryBackend + +shield_app = ShieldServer( + backend=MemoryBackend(), + auth=("admin", "secret"), + token_expiry=3600, # dashboard / CLI users: 1 hour + sdk_token_expiry=31536000, # SDK service tokens: 1 year (default) +) +# Run: uvicorn myapp:shield_app --port 9000 +``` + +**Service setup β€” three auth configurations:** + +```python +from shield.sdk import ShieldSDK +import os + +# No auth on the Shield Server β€” nothing needed +sdk = ShieldSDK(server_url="http://shield-server:9000", app_id="payments-service") + +# Auto-login (recommended for production): SDK logs in on startup with platform="sdk" +sdk = ShieldSDK( + server_url=os.environ["SHIELD_SERVER_URL"], + app_id="payments-service", + username=os.environ["SHIELD_USERNAME"], + password=os.environ["SHIELD_PASSWORD"], +) + +# Pre-issued token: obtain once via `shield login`, store as a secret +sdk = ShieldSDK( + server_url=os.environ["SHIELD_SERVER_URL"], + app_id="payments-service", + token=os.environ["SHIELD_TOKEN"], +) + +sdk.attach(app) # wires middleware + startup/shutdown +``` + +### Which backend should the Shield Server use? + +| Shield Server instances | Backend choice | +|---|---| +| 1 (development) | `MemoryBackend` β€” state lives in-process, lost on restart | +| 1 (production) | `FileBackend` β€” state survives restarts | +| 2+ (HA / load-balanced) | `RedisBackend` β€” all Shield Server nodes share state via pub/sub | + +### Shared rate limit counters across SDK replicas + +Each SDK client enforces rate limits locally using its own counters. When a service runs multiple replicas, each replica has independent counters β€” a `100/minute` limit is enforced independently per replica by default. + +To enforce the limit **across all replicas combined**, pass a shared `RedisBackend` as `rate_limit_backend`: + +```python +from shield.core.backends.redis import RedisBackend + +sdk = ShieldSDK( + server_url="http://shield-server:9000", + app_id="payments-service", + rate_limit_backend=RedisBackend(url="redis://redis:6379/1"), +) +``` + +### Deployment matrix + +| Services | Replicas per service | Shield Server backend | SDK `rate_limit_backend` | +|---|---|---|---| +| 1 | 1 | any β€” use embedded `ShieldAdmin` instead | β€” | +| 2+ | 1 each | `MemoryBackend` or `FileBackend` | not needed | +| 2+ | 2+ each | `RedisBackend` | `RedisBackend` | + +See [**Shield Server guide β†’**](../guides/shield-server.md) for a complete walkthrough. + +--- + ## Using `make_engine` (recommended) `make_engine()` reads `SHIELD_BACKEND` (and related env vars) so you never hardcode the backend: diff --git a/docs/tutorial/cli.md b/docs/tutorial/cli.md index 0139a55..6cf8795 100644 --- a/docs/tutorial/cli.md +++ b/docs/tutorial/cli.md @@ -111,6 +111,49 @@ shield env clear /api/debug # remove the gate, restore to A --- +## Multi-service context + +When the Shield Server manages multiple services, scope every command to the right service. + +### Option A β€” `SHIELD_SERVICE` env var (recommended) + +```bash +export SHIELD_SERVICE=payments-service +shield status # only payments-service routes +shield disable GET:/payments --reason "hotfix" +shield enable GET:/payments +``` + +All route commands (`status`, `enable`, `disable`, `maintenance`, `schedule`) read `SHIELD_SERVICE` automatically. An explicit `--service` flag always overrides it. + +### Option B β€” `--service` flag per command + +```bash +shield status --service payments-service +shield disable GET:/payments --service payments-service --reason "hotfix" +``` + +### Discover active context and connected services + +```bash +shield current-service # show which service SHIELD_SERVICE points to +shield services # list all services registered with the Shield Server +``` + +??? example "Sample `shield services` output" + + ``` + Connected services + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Service β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ orders-service β”‚ + β”‚ payments-service β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ``` + +--- + ## Rate limits Manage rate limit policies and view blocked requests. Requires `api-shield[rate-limit]` on the server. @@ -132,6 +175,9 @@ shield rate-limits list shield rate-limits set GET:/public/posts 20/minute ``` +!!! tip "SDK clients receive policy changes in real time" + When using Shield Server + ShieldSDK, rate limit policies set via `shield rl set` are broadcast over the SSE stream and applied to every connected SDK client immediately β€” no restart required. + ??? example "Sample `shield rl list` output" | Route | Limit | Algorithm | Key Strategy | diff --git a/docs/tutorial/rate-limiting.md b/docs/tutorial/rate-limiting.md index e10af19..6468715 100644 --- a/docs/tutorial/rate-limiting.md +++ b/docs/tutorial/rate-limiting.md @@ -375,6 +375,34 @@ shield rl delete GET:/public/posts # remove persisted overr !!! note "Decorator metadata is the initial state" The limit declared in `@rate_limit(...)` is the startup default. If a policy is mutated at runtime and persisted (file or Redis backend), the persisted value takes effect on restart, just like route state. +!!! note "Route must exist" + `shield rl set` (and `engine.set_rate_limit_policy()`) validate that the route is registered before saving the policy. If the route does not exist, the CLI prints an error and no policy is created. This prevents phantom policies for typos or stale routes. + +!!! tip "SDK clients receive changes in real time" + When using Shield Server + ShieldSDK, policies set via `shield rl set` or the dashboard are broadcast over the SSE stream as typed `rl_policy` events and applied to every connected SDK client immediately β€” no restart required. The propagation delay is the SSE round-trip (typically under 5 ms on a LAN). + +--- + +## Setting limits from the dashboard (Unprotected Routes) + +The **Rate Limits** tab in the admin dashboard (`/shield/rate-limits`) includes an **Unprotected Routes** section that lists every registered route that currently has no rate limit policy. This is useful for: + +- Auditing which routes are exposed without throttling +- Adding limits to routes that were not annotated with `@rate_limit` at deploy time + +Each row has an **Add Limit** button that opens a modal where you can configure: + +| Field | Description | +|---|---| +| Limit | Limit string, e.g. `100/minute` | +| Algorithm | `fixed_window`, `sliding_window`, `moving_window`, `token_bucket` | +| Key strategy | `ip`, `user`, `api_key`, `global` | +| Burst | Extra requests above the base limit | + +The HTTP method is read directly from the route key and shown in the modal header β€” no separate field to fill in. + +Submitting the form calls `POST /api/rate-limits` under the hood and redirects back to the Rate Limits page with the new policy visible immediately. The policy behaves identically to one declared with `@rate_limit` β€” it is persisted, survives restarts (on file or Redis backends), and propagates to SDK clients via SSE. + --- ## Blocked requests log diff --git a/examples/fastapi/multi_service.py b/examples/fastapi/multi_service.py new file mode 100644 index 0000000..bf015a9 --- /dev/null +++ b/examples/fastapi/multi_service.py @@ -0,0 +1,314 @@ +"""FastAPI β€” Multi-Service Shield Server Example. + +Demonstrates two independent FastAPI services (payments and orders) both +connecting to the same Shield Server. Each service registers its routes +under its own app_id namespace so the dashboard and CLI can manage them +independently or together. + +This file defines THREE separate ASGI apps. Run each in its own terminal: + + Shield Server (port 8001): + uv run uvicorn examples.fastapi.multi_service:shield_app --port 8001 --reload + + Payments service (port 8000): + uv run uvicorn examples.fastapi.multi_service:payments_app --port 8000 --reload + + Orders service (port 8002): + uv run uvicorn examples.fastapi.multi_service:orders_app --port 8002 --reload + +Then visit: + http://localhost:8001/ β€” Shield dashboard (admin / secret) + Use the service dropdown to switch between + "payments-service" and "orders-service" + http://localhost:8000/docs β€” Payments Swagger UI + http://localhost:8002/docs β€” Orders Swagger UI + +CLI β€” points at the Shield Server; use --service or SHIELD_SERVICE to scope: + + # One-time setup + shield config set-url http://localhost:8001 + shield login admin # password: secret + + # View all registered services + shield services + + # Manage payments routes + export SHIELD_SERVICE=payments-service + shield status + shield disable /api/payments --reason "hotfix" + shield enable /api/payments + + # Switch to orders without changing env var + shield status --service orders-service + + # Explicit --service flag overrides the env var + export SHIELD_SERVICE=payments-service + shield enable /api/orders --service orders-service + + # Clear the env var to work across all services at once + unset SHIELD_SERVICE + shield status # shows routes from both services + shield audit # audit log from both services + + # Global maintenance β€” affects ALL services + shield global disable --reason "emergency maintenance" + shield global enable + +Expected behaviour: + Payments (port 8000): + GET /health β†’ 200 always (@force_active) + GET /api/payments β†’ 503 MAINTENANCE_MODE (starts in maintenance) + GET /api/refunds β†’ 200 (active) + GET /api/v1/invoices β†’ 200 + deprecation hdr (@deprecated) + GET /api/v2/invoices β†’ 200 (active successor) + + Orders (port 8002): + GET /health β†’ 200 always (@force_active) + GET /api/orders β†’ 200 (active) + GET /api/shipments β†’ 503 ROUTE_DISABLED (@disabled) + GET /api/cart β†’ 200 (active) + +Production notes: + Backend choice affects the Shield Server only. All SDK clients receive + live SSE updates regardless of backend β€” they connect to the Shield Server + over HTTP, never to the backend directly: + + * MemoryBackend β€” fine for development; state lost on Shield Server restart. + * FileBackend β€” state survives restarts; single Shield Server instance only. + * RedisBackend β€” needed only when running multiple Shield Server instances + (HA / load-balanced). Redis pub/sub keeps all nodes in + sync so every SDK client sees consistent state. + + * Use a stable secret_key so tokens survive Shield Server restarts. + * Prefer passing username/password to each ShieldSDK so each service + obtains its own sdk-platform token on startup automatically. + * Set token_expiry (dashboard/CLI) and sdk_token_expiry (services) + independently so human sessions stay short-lived. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from shield.core.backends.memory import MemoryBackend +from shield.fastapi import ( + ShieldRouter, + apply_shield_to_openapi, + deprecated, + disabled, + force_active, + maintenance, +) +from shield.sdk import ShieldSDK +from shield.server import ShieldServer + +# --------------------------------------------------------------------------- +# Shield Server β€” shared by all services +# --------------------------------------------------------------------------- +# Run: uv run uvicorn examples.fastapi.multi_service:shield_app --port 8001 --reload +# +# All services register their routes here. The dashboard service dropdown +# lets you filter and manage each service independently. +# +# For production: swap MemoryBackend for RedisBackend: +# from shield.core.backends.redis import RedisBackend +# backend = RedisBackend("redis://localhost:6379") + +shield_app = ShieldServer( + backend=MemoryBackend(), + auth=("admin", "secret"), + # secret_key="change-me-in-production", + # token_expiry=3600, # dashboard / CLI sessions β€” default 24 h + # sdk_token_expiry=31536000, # SDK service tokens β€” default 1 year +) + +# --------------------------------------------------------------------------- +# Payments Service (port 8000) +# --------------------------------------------------------------------------- +# Run: uv run uvicorn examples.fastapi.multi_service:payments_app --port 8000 --reload +# +# app_id="payments-service" namespaces all routes from this service on the +# Shield Server. The dashboard shows them separately from orders-service. +# CLI: export SHIELD_SERVICE=payments-service; shield status + +payments_sdk = ShieldSDK( + server_url="http://localhost:8001", + app_id="payments-service", + # Auto-login (recommended): SDK obtains a 1-year sdk-platform token on startup. + # username="admin", # inject from env: os.environ["SHIELD_USERNAME"] + # password="secret", # inject from env: os.environ["SHIELD_PASSWORD"] + # Or use a pre-issued token: token=os.environ["SHIELD_TOKEN"] + reconnect_delay=5.0, +) + +payments_app = FastAPI( + title="api-shield β€” Payments Service", + description=( + "Connects to the Shield Server at **http://localhost:8001** as " + "`payments-service`. Manage routes from the " + "[Shield Dashboard](http://localhost:8001/) or via the CLI with " + "`export SHIELD_SERVICE=payments-service`." + ), +) + +payments_sdk.attach(payments_app) + +payments_router = ShieldRouter(engine=payments_sdk.engine) + + +@payments_router.get("/health") +@force_active +async def payments_health(): + """Always 200 β€” load-balancer probe endpoint.""" + return {"status": "ok", "service": "payments-service"} + + +@payments_router.get("/api/payments") +@maintenance(reason="Payment processor upgrade β€” back at 04:00 UTC") +async def process_payment(): + """Returns 503 MAINTENANCE_MODE on startup. + + Restore from the CLI: + export SHIELD_SERVICE=payments-service + shield enable /api/payments + """ + return {"payment_id": "pay_abc123", "status": "processed"} + + +@payments_router.get("/api/refunds") +async def list_refunds(): + """Active on startup. + + Disable from the CLI: + shield disable /api/refunds --reason "audit in progress" \\ + --service payments-service + """ + return {"refunds": [{"id": "ref_001", "amount": 49.99}]} + + +@payments_router.get("/api/v1/invoices") +@deprecated(sunset="Sat, 01 Jun 2028 00:00:00 GMT", use_instead="/api/v2/invoices") +async def v1_invoices(): + """Returns 200 with Deprecation, Sunset, and Link response headers.""" + return {"invoices": [{"id": "inv_001", "total": 199.99}], "version": 1} + + +@payments_router.get("/api/v2/invoices") +async def v2_invoices(): + """Active successor to /api/v1/invoices.""" + return {"invoices": [{"id": "inv_001", "total": 199.99}], "version": 2} + + +payments_app.include_router(payments_router) +apply_shield_to_openapi(payments_app, payments_sdk.engine) + +# --------------------------------------------------------------------------- +# Orders Service (port 8002) +# --------------------------------------------------------------------------- +# Run: uv run uvicorn examples.fastapi.multi_service:orders_app --port 8002 --reload +# +# app_id="orders-service" gives this service its own namespace on the server. +# CLI: export SHIELD_SERVICE=orders-service; shield status + +orders_sdk = ShieldSDK( + server_url="http://localhost:8001", + app_id="orders-service", + # Auto-login (recommended): SDK obtains a 1-year sdk-platform token on startup. + # username="admin", # inject from env: os.environ["SHIELD_USERNAME"] + # password="secret", # inject from env: os.environ["SHIELD_PASSWORD"] + # Or use a pre-issued token: token=os.environ["SHIELD_TOKEN"] + reconnect_delay=5.0, +) + +orders_app = FastAPI( + title="api-shield β€” Orders Service", + description=( + "Connects to the Shield Server at **http://localhost:8001** as " + "`orders-service`. Manage routes from the " + "[Shield Dashboard](http://localhost:8001/) or via the CLI with " + "`export SHIELD_SERVICE=orders-service`." + ), +) + +orders_sdk.attach(orders_app) + +orders_router = ShieldRouter(engine=orders_sdk.engine) + + +@orders_router.get("/health") +@force_active +async def orders_health(): + """Always 200 β€” load-balancer probe endpoint.""" + return {"status": "ok", "service": "orders-service"} + + +@orders_router.get("/api/orders") +async def list_orders(): + """Active on startup. + + Disable from the CLI: + shield disable /api/orders --reason "inventory sync" \\ + --service orders-service + """ + return {"orders": [{"id": 42, "status": "shipped"}]} + + +@orders_router.get("/api/shipments") +@disabled(reason="Shipment provider integration deprecated β€” use /api/orders") +async def list_shipments(): + """Returns 503 ROUTE_DISABLED. + + Re-enable from the CLI if you need to temporarily restore access: + shield enable /api/shipments --service orders-service + """ + return {} + + +@orders_router.get("/api/cart") +async def get_cart(): + """Active on startup. + + Put the whole orders-service in global maintenance from the dashboard + or pause just this route: + shield maintenance /api/cart --reason "cart redesign" \\ + --service orders-service + """ + return {"cart": {"items": [], "total": 0.0}} + + +orders_app.include_router(orders_router) +apply_shield_to_openapi(orders_app, orders_sdk.engine) + +# --------------------------------------------------------------------------- +# CLI reference β€” multi-service workflow +# --------------------------------------------------------------------------- +# +# Setup (once): +# shield config set-url http://localhost:8001 +# shield login admin +# +# View all services and their routes: +# shield services +# shield status # routes from ALL services combined +# +# Scope to a specific service via env var: +# export SHIELD_SERVICE=payments-service +# shield status # only payments-service routes +# shield disable /api/payments --reason "hotfix" +# shield enable /api/payments +# shield maintenance /api/refunds --reason "audit" +# +# Switch service without changing the env var (--service flag): +# shield status --service orders-service +# shield disable /api/orders --reason "inventory sync" \\ +# --service orders-service +# +# Explicit flag always overrides the SHIELD_SERVICE env var: +# export SHIELD_SERVICE=payments-service +# shield enable /api/orders --service orders-service # acts on orders +# +# Unscoped commands operate across all services: +# unset SHIELD_SERVICE +# shield audit # audit log from both services +# shield global disable --reason "emergency maintenance" +# shield global enable diff --git a/examples/fastapi/shield_server.py b/examples/fastapi/shield_server.py new file mode 100644 index 0000000..6d9798d --- /dev/null +++ b/examples/fastapi/shield_server.py @@ -0,0 +1,255 @@ +"""FastAPI β€” Shield Server Mode Example. + +Demonstrates the centralized Shield Server architecture: a single Shield +Server process owns all route state, and one or more service apps connect +to it via ShieldSDK. State is enforced locally on every request with zero +network overhead β€” the SDK keeps an in-process cache synced over a +persistent SSE connection. + +This file defines TWO separate ASGI apps. Run them in separate terminals: + + App 1 β€” The Shield Server (port 8001): + uv run uvicorn examples.fastapi.shield_server:shield_app --port 8001 --reload + + App 2 β€” The Service App (port 8000): + uv run uvicorn examples.fastapi.shield_server:service_app --port 8000 --reload + +Then visit: + http://localhost:8001/ β€” Shield Server dashboard (admin / secret) + http://localhost:8001/audit β€” audit log (all services) + http://localhost:8000/docs β€” service Swagger UI + +CLI β€” always points at the Shield Server, not the service: + shield config set-url http://localhost:8001 + shield login admin # password: secret + shield status # routes registered by my-service + shield disable /api/orders --reason "hotfix" + shield enable /api/orders + shield maintenance /api/payments --reason "DB migration" + shield audit # full audit trail + +Expected behaviour: + GET /health β†’ 200 always (@force_active β€” survives disable) + GET /api/payments β†’ 503 MAINTENANCE_MODE (starts in maintenance) + GET /api/orders β†’ 200 (active on startup) + GET /api/legacy β†’ 503 ROUTE_DISABLED (@disabled) + GET /api/v1/products β†’ 200 + deprecation hdr (@deprecated) + GET /api/v2/products β†’ 200 (active successor) + +Production notes: + Backend choice affects the Shield Server only β€” SDK clients always receive + live SSE updates regardless of backend, because they connect to the Shield + Server over HTTP (not to the backend directly): + + * MemoryBackend β€” fine for development; state is lost when the Shield + Server restarts. + * FileBackend β€” state survives restarts; safe for single-server + deployments (no multi-process file locking). + * RedisBackend β€” required only when you run multiple Shield Server + instances behind a load balancer (high availability). + Cross-instance pub/sub keeps all Shield Server nodes + in sync so every SDK client gets consistent state. + + * Use a stable secret_key so tokens survive Shield Server restarts. + * Prefer passing username/password to ShieldSDK so the SDK obtains its + own sdk-platform token on startup (sdk_token_expiry, default 1 year) + rather than managing a pre-issued token manually. + * Set token_expiry (dashboard/CLI sessions) and sdk_token_expiry (service + tokens) independently so human sessions stay short-lived. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from shield.core.backends.memory import MemoryBackend +from shield.fastapi import ( + ShieldRouter, + apply_shield_to_openapi, + deprecated, + disabled, + force_active, + maintenance, +) +from shield.sdk import ShieldSDK +from shield.server import ShieldServer + +# --------------------------------------------------------------------------- +# App 1 β€” Shield Server +# --------------------------------------------------------------------------- +# Run: uv run uvicorn examples.fastapi.shield_server:shield_app --port 8001 --reload +# +# The Shield Server is a self-contained ASGI app that exposes: +# / β€” HTMX dashboard UI (login: admin / secret) +# /audit β€” audit log +# /api/... β€” REST API consumed by the CLI +# /api/sdk/... β€” SSE + register endpoints consumed by ShieldSDK clients +# +# For production: swap MemoryBackend for RedisBackend so every connected +# service receives live state updates via the SSE channel. +# +# from shield.core.backends.redis import RedisBackend +# backend = RedisBackend("redis://localhost:6379") +# +# secret_key should be a stable value so issued tokens survive restarts. +# Omit it (or pass None) in development β€” a random key is generated per run. + +shield_app = ShieldServer( + backend=MemoryBackend(), + auth=("admin", "secret"), + # secret_key="change-me-in-production", + # token_expiry=3600, # dashboard / CLI sessions β€” default 24 h + # sdk_token_expiry=31536000, # SDK service tokens β€” default 1 year +) + +# --------------------------------------------------------------------------- +# App 2 β€” Service App +# --------------------------------------------------------------------------- +# Run: uv run uvicorn examples.fastapi.shield_server:service_app --port 8000 --reload +# +# ShieldSDK wires ShieldMiddleware + startup/shutdown lifecycle into the app. +# Route enforcement is purely local β€” the SDK never adds per-request latency. +# +# Authentication options (choose one): +# +# 1. Auto-login β€” recommended for production. The SDK calls +# POST /api/auth/login with platform="sdk" on startup and caches the +# returned token (valid for sdk_token_expiry, default 1 year). +# Inject credentials from environment variables: +# +# sdk = ShieldSDK( +# server_url="http://localhost:8001", +# app_id="my-service", +# username=os.environ["SHIELD_USERNAME"], +# password=os.environ["SHIELD_PASSWORD"], +# ) +# +# 2. Pre-issued token β€” obtain once via `shield login`, store as a secret: +# sdk = ShieldSDK(..., token=os.environ["SHIELD_TOKEN"]) +# +# 3. No auth β€” omit token/username/password when the Shield Server has +# no auth configured (auth=None or auth omitted). + +sdk = ShieldSDK( + server_url="http://localhost:8001", + app_id="my-service", + # username="admin", # or inject from env: os.environ["SHIELD_USERNAME"] + # password="secret", # or inject from env: os.environ["SHIELD_PASSWORD"] + reconnect_delay=5.0, # seconds between SSE reconnect attempts +) + +service_app = FastAPI( + title="api-shield β€” Shield Server Example (Service)", + description=( + "Connects to the Shield Server at **http://localhost:8001** via " + "ShieldSDK. All route state is managed centrally β€” use the " + "[Shield Dashboard](http://localhost:8001/) or the CLI to " + "enable, disable, or pause any route without redeploying." + ), +) + +# attach() adds ShieldMiddleware and wires startup/shutdown hooks. +# Call this BEFORE defining routes so the router below can use sdk.engine. +sdk.attach(service_app) + +# ShieldRouter auto-registers decorated routes with the Shield Server on +# startup so they appear in the dashboard immediately. +router = ShieldRouter(engine=sdk.engine) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@router.get("/health") +@force_active +async def health(): + """Always 200 β€” bypasses every shield check. + + Use this for load-balancer probes. @force_active ensures the route + stays reachable even if the Shield Server is temporarily unreachable + and the SDK falls back to its empty local cache. + """ + return {"status": "ok", "service": "my-service"} + + +@router.get("/api/payments") +@maintenance(reason="Scheduled database migration β€” back at 04:00 UTC") +async def get_payments(): + """Returns 503 MAINTENANCE_MODE on startup. + + Lift maintenance from the CLI (no redeploy needed): + shield enable /api/payments + """ + return {"payments": [{"id": 1, "amount": 99.99}]} + + +@router.get("/api/orders") +async def list_orders(): + """Active on startup β€” disable from the CLI: + + shield disable /api/orders --reason "hotfix" + shield enable /api/orders + """ + return {"orders": [{"id": 42, "status": "shipped"}]} + + +@router.get("/api/legacy") +@disabled(reason="Use /api/v2/products instead") +async def legacy_endpoint(): + """Returns 503 ROUTE_DISABLED. + + The @disabled state is set at deploy time and can be overridden from + the dashboard or CLI: + shield enable /api/legacy + """ + return {} + + +@router.get("/api/v1/products") +@deprecated(sunset="Sat, 01 Jan 2028 00:00:00 GMT", use_instead="/api/v2/products") +async def v1_products(): + """Returns 200 with Deprecation, Sunset, and Link response headers. + + Headers injected by ShieldMiddleware on every response: + Deprecation: true + Sunset: Sat, 01 Jan 2028 00:00:00 GMT + Link: ; rel="successor-version" + """ + return {"products": [{"id": 1, "name": "Widget"}], "version": 1} + + +@router.get("/api/v2/products") +async def v2_products(): + """Active successor to /api/v1/products.""" + return {"products": [{"id": 1, "name": "Widget"}], "version": 2} + + +service_app.include_router(router) +apply_shield_to_openapi(service_app, sdk.engine) + +# --------------------------------------------------------------------------- +# How the CLI talks to this setup +# --------------------------------------------------------------------------- +# +# The CLI always communicates with the Shield Server, never directly with +# the service app. From the Shield Server's perspective, routes from +# "my-service" appear namespaced as "my-service:/api/payments" etc. +# +# # One-time setup +# shield config set-url http://localhost:8001 +# shield login admin +# +# # Inspect state +# shield status # all routes for my-service +# shield audit # full audit trail +# +# # Lifecycle management +# shield disable /api/orders --reason "hotfix" +# shield enable /api/orders +# shield maintenance /api/payments --reason "scheduled downtime" +# shield schedule /api/payments # set maintenance window +# +# # Dashboard +# open http://localhost:8001/ # full UI, no CLI needed diff --git a/mkdocs.yml b/mkdocs.yml index c1ae9dd..77f927a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,8 +110,9 @@ nav: - Exceptions: reference/exceptions.md - CLI Commands: reference/cli.md - Guides: - - Production Monitoring & Deployment: guides/production.md + - Shield Server (Multi-Service): guides/shield-server.md - Distributed Deployments: guides/distributed.md + - Production Monitoring & Deployment: guides/production.md - Adapters: - Overview: adapters/index.md - FastAPI: adapters/fastapi.md diff --git a/package.json b/package.json index 67ef98b..ec27e42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "scripts": { - "build:css": "tailwindcss -i input.css -o shield/dashboard/static/shield.min.css --minify", + "build:css": "tailwindcss -i input.css -o shield/dashboard/static/shield.min.css --minify && node -e \"const f='shield/dashboard/static/shield.min.css',fs=require('fs');fs.writeFileSync(f,fs.readFileSync(f,'utf8').trimEnd())\"", "watch:css": "tailwindcss -i input.css -o shield/dashboard/static/shield.min.css --watch" }, "devDependencies": { diff --git a/shield/admin/api.py b/shield/admin/api.py index daa561b..6fa1a65 100644 --- a/shield/admin/api.py +++ b/shield/admin/api.py @@ -18,12 +18,14 @@ from __future__ import annotations +import asyncio import base64 import logging from datetime import UTC, datetime +from typing import Any from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, StreamingResponse from shield.core.engine import ShieldEngine from shield.core.exceptions import ( @@ -31,7 +33,7 @@ RouteNotFoundException, RouteProtectedException, ) -from shield.core.models import MaintenanceWindow +from shield.core.models import AuditEntry, MaintenanceWindow, RouteState logger = logging.getLogger(__name__) @@ -100,6 +102,9 @@ async def auth_login(request: Request) -> JSONResponse: username = body.get("username", "") if isinstance(body, dict) else "" password = body.get("password", "") if isinstance(body, dict) else "" + platform = body.get("platform", "cli") if isinstance(body, dict) else "cli" + if platform not in ("cli", "sdk"): + platform = "cli" if not username or not password: return _err("username and password are required") @@ -107,7 +112,7 @@ async def auth_login(request: Request) -> JSONResponse: if not auth_backend.authenticate_user(username, password): return _err("Invalid credentials", 401) - token, expires_at = tm.create(username, platform="cli") + token, expires_at = tm.create(username, platform=platform) return JSONResponse( { "token": token, @@ -136,8 +141,14 @@ async def auth_me(request: Request) -> JSONResponse: async def list_routes(request: Request) -> JSONResponse: - """GET /api/routes β€” list all registered route states.""" + """GET /api/routes β€” list all registered route states. + + Optional query param ``?service=`` filters to a single service. + """ states = await _engine(request).list_states() + service = request.query_params.get("service") + if service: + states = [s for s in states if s.service == service] return JSONResponse([s.model_dump(mode="json") for s in states]) @@ -300,13 +311,21 @@ async def cancel_schedule_route(request: Request) -> JSONResponse: async def list_audit(request: Request) -> JSONResponse: - """GET /api/audit β€” return audit log entries (newest first).""" + """GET /api/audit β€” return audit log entries (newest first). + + Optional query params: + - ``?route=`` β€” filter by exact route path + - ``?service=`` β€” filter to a single service (SDK mode) + """ route = request.query_params.get("route") + service = request.query_params.get("service") try: limit = int(request.query_params.get("limit", "50")) except ValueError: limit = 50 entries = await _engine(request).get_audit_log(path=route, limit=limit) + if service: + entries = [e for e in entries if e.service == service] return JSONResponse([e.model_dump(mode="json") for e in entries]) @@ -408,6 +427,8 @@ async def set_rate_limit_policy_api(request: Request) -> JSONResponse: burst=int(body.get("burst", 0)), actor=actor, ) + except RouteNotFoundException as exc: + return JSONResponse({"error": str(exc)}, status_code=404) except Exception as exc: return JSONResponse({"error": str(exc)}, status_code=400) @@ -509,3 +530,177 @@ async def disable_global_rate_limit_api(request: Request) -> JSONResponse: actor = _actor(request) await engine.disable_global_rate_limit(actor=actor, platform=_platform(request)) return JSONResponse({"ok": True}) + + +# --------------------------------------------------------------------------- +# SDK endpoints β€” used by ShieldServerBackend / ShieldSDK clients +# --------------------------------------------------------------------------- + + +async def sdk_events(request: Request) -> StreamingResponse: + """GET /api/sdk/events β€” SSE stream of typed route state and RL policy changes. + + SDK clients (``ShieldServerBackend``) connect here to keep their + local cache current without polling. Each event is a typed JSON + envelope: + + * Route state change:: + + data: {"type": "state", "payload": {...RouteState...}} + + * Rate limit policy change:: + + data: {"type": "rl_policy", "action": "set", "key": "GET:/api/pay", "policy": {...}} + data: {"type": "rl_policy", "action": "delete", "key": "GET:/api/pay"} + + When a backend does not support ``subscribe()`` (e.g. FileBackend) + the endpoint falls back to 15-second keepalive pings so clients + maintain their connection and rely on the full re-sync performed + after each reconnect. + """ + import json as _json + + engine = _engine(request) + queue: asyncio.Queue[str] = asyncio.Queue() + tasks: list[asyncio.Task[None]] = [] + + async def _feed_states() -> None: + try: + async for state in engine.backend.subscribe(): + envelope = _json.dumps({"type": "state", "payload": state.model_dump(mode="json")}) + await queue.put(f"data: {envelope}\n\n") + except NotImplementedError: + pass + except asyncio.CancelledError: + raise + except Exception: + logger.exception("shield: SDK SSE state subscription error") + + async def _feed_rl_policies() -> None: + try: + async for event in engine.backend.subscribe_rate_limit_policy(): + envelope = _json.dumps({"type": "rl_policy", **event}) + await queue.put(f"data: {envelope}\n\n") + except NotImplementedError: + pass + except asyncio.CancelledError: + raise + except Exception: + logger.exception("shield: SDK SSE RL policy subscription error") + + async def _generate() -> object: + tasks.append(asyncio.create_task(_feed_states())) + tasks.append(asyncio.create_task(_feed_rl_policies())) + try: + while True: + try: + # Block until an event arrives or 15 s elapses. + msg = await asyncio.wait_for(queue.get(), timeout=15.0) + yield msg + except TimeoutError: + # No event in 15 s β€” send a keepalive comment to hold the connection. + yield ": keepalive\n\n" + except asyncio.CancelledError: + break + finally: + for t in tasks: + t.cancel() + + return StreamingResponse( + _generate(), # type: ignore[arg-type] + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +async def sdk_register(request: Request) -> JSONResponse: + """POST /api/sdk/register β€” batch-register routes from an SDK client. + + Applies server-wins semantics: routes that already exist in the + backend are left untouched and their current state is returned. + New routes are created with the initial state supplied by the SDK. + + The SDK sends states with ``path = "{app_id}:{original_path}"`` and + ``service = app_id`` already set. This endpoint trusts those values + directly β€” no further rewriting is done here. + + Request body:: + + { + "app_id": "payments-service", + "states": [ ...RouteState dicts with service-prefixed paths... ] + } + + Response:: + + {"states": [ ...current RouteState dicts... ]} + """ + engine = _engine(request) + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + app_id = body.get("app_id", "unknown") if isinstance(body, dict) else "unknown" + states_data = body.get("states", []) if isinstance(body, dict) else [] + if not isinstance(states_data, list): + return _err("states must be a list") + + results: list[dict[str, Any]] = [] + for state_dict in states_data: + try: + incoming = RouteState.model_validate(state_dict) + except Exception: + continue + + # Ensure service field is always populated from app_id for legacy clients + # that do not set it themselves. + if not incoming.service: + incoming = incoming.model_copy(update={"service": app_id}) + + # Server-wins: if this namespaced key already exists, keep server state. + try: + existing = await engine.backend.get_state(incoming.path) + results.append(existing.model_dump(mode="json")) + except KeyError: + await engine.backend.set_state(incoming.path, incoming) + results.append(incoming.model_dump(mode="json")) + + logger.debug("shield: SDK registered %d route(s) from app_id=%s", len(results), app_id) + return JSONResponse({"states": results}) + + +async def list_services(request: Request) -> JSONResponse: + """GET /api/services β€” return the distinct service names across all routes. + + Used by the dashboard dropdown and CLI to discover which services have + registered routes with this Shield Server. Routes without a service + (embedded-mode routes) are not included. + """ + states = await _engine(request).list_states() + services = sorted({s.service for s in states if s.service}) + return JSONResponse(services) + + +async def sdk_audit(request: Request) -> JSONResponse: + """POST /api/sdk/audit β€” receive an audit entry forwarded by an SDK client. + + SDK clients forward audit entries here so the Shield Server maintains + a unified audit log across all connected services. + """ + engine = _engine(request) + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + try: + entry = AuditEntry.model_validate(body) + except Exception as exc: + return _err(f"Invalid audit entry: {exc}") + + await engine.backend.write_audit(entry) + return JSONResponse({"ok": True}) diff --git a/shield/admin/app.py b/shield/admin/app.py index dd8d73a..39e773a 100644 --- a/shield/admin/app.py +++ b/shield/admin/app.py @@ -215,6 +215,7 @@ def ShieldAdmin( engine: ShieldEngine, auth: AuthConfig = None, token_expiry: int = 86400, + sdk_token_expiry: int = 31536000, secret_key: str | None = None, prefix: str = "/shield", ) -> ASGIApp: @@ -234,8 +235,13 @@ def ShieldAdmin( ``(username, password)`` tuple, a list of such tuples, or a custom :class:`~shield.admin.auth.ShieldAuthBackend` instance. token_expiry: - Session / token lifetime in seconds. Default: 86400 (24 h). - After expiry the user must re-authenticate. + Session / token lifetime in seconds for dashboard and CLI users. + Default: 86400 (24 h). After expiry the user must re-authenticate. + sdk_token_expiry: + Token lifetime in seconds for SDK service tokens issued with + ``platform="sdk"``. Default: 31536000 (1 year). This lets + service apps authenticate once and run indefinitely without + human intervention, while keeping human user sessions short. secret_key: HMAC signing key for tokens. Use a stable value in production so tokens survive process restarts. Defaults to a random key (tokens @@ -257,6 +263,30 @@ def ShieldAdmin( ) templates.env.globals["path_slug"] = _dash.path_slug + def _clean_path(state: object) -> str: + """Return the display path without the service prefix. + + SDK routes are stored with ``path = "{service}:{original_path}"``. + This filter strips the prefix so the dashboard shows ``/api/payments`` + rather than ``payments-service:/api/payments``. + """ + svc = getattr(state, "service", None) + raw = getattr(state, "path", "") + if svc and raw.startswith(f"{svc}:"): + return raw[len(svc) + 1 :] + return raw + + def _clean_entry_path(entry: object) -> str: + """Same as _clean_path but works on AuditEntry objects.""" + svc = getattr(entry, "service", None) + raw = getattr(entry, "path", "") + if svc and raw.startswith(f"{svc}:"): + return raw[len(svc) + 1 :] + return raw + + templates.env.filters["clean_path"] = _clean_path + templates.env.filters["clean_entry_path"] = _clean_entry_path + try: version = importlib.metadata.version("api-shield") except importlib.metadata.PackageNotFoundError: @@ -266,6 +296,7 @@ def ShieldAdmin( token_manager = TokenManager( secret_key=secret_key, expiry_seconds=token_expiry, + sdk_token_expiry=sdk_token_expiry, auth_fingerprint=auth_fingerprint(auth), ) @@ -310,9 +341,11 @@ def ShieldAdmin( Route("/blocked", _dash.rl_hits_page), Route("/modal/rl/reset/{path_key}", _dash.modal_rl_reset), Route("/modal/rl/edit/{path_key}", _dash.modal_rl_edit), + Route("/modal/rl/add/{path_key}", _dash.modal_rl_add), Route("/modal/rl/delete/{path_key}", _dash.modal_rl_delete), Route("/rl/reset/{path_key}", _dash.rl_reset, methods=["POST"]), Route("/rl/edit/{path_key}", _dash.rl_edit, methods=["POST"]), + Route("/rl/add", _dash.rl_add, methods=["POST"]), Route("/rl/delete/{path_key}", _dash.rl_delete, methods=["POST"]), Route("/global-rl/set", _dash.global_rl_set, methods=["POST"]), Route("/global-rl/delete", _dash.global_rl_delete, methods=["POST"]), @@ -383,6 +416,12 @@ def ShieldAdmin( _api.disable_global_rate_limit_api, methods=["POST"], ), + # ── SDK endpoints (ShieldServerBackend / ShieldSDK) ────────── + Route("/api/sdk/events", _api.sdk_events, methods=["GET"]), + Route("/api/sdk/register", _api.sdk_register, methods=["POST"]), + Route("/api/sdk/audit", _api.sdk_audit, methods=["POST"]), + # ── Service discovery ──────────────────────────────────────── + Route("/api/services", _api.list_services, methods=["GET"]), ], ) diff --git a/shield/admin/auth.py b/shield/admin/auth.py index 90cdd21..17b6fb1 100644 --- a/shield/admin/auth.py +++ b/shield/admin/auth.py @@ -162,6 +162,7 @@ def __init__( self, secret_key: str | None = None, expiry_seconds: int = 86400, + sdk_token_expiry: int = 31536000, auth_fingerprint: str = "", ) -> None: """ @@ -171,7 +172,13 @@ def __init__( HMAC signing key. If ``None`` a random key is generated β€” tokens will be invalidated on process restart. expiry_seconds: - Token lifetime in seconds. Default: 86400 (24 hours). + Token lifetime in seconds for dashboard and CLI users. + Default: 86400 (24 hours). + sdk_token_expiry: + Token lifetime in seconds for SDK service tokens + (``platform="sdk"``). Default: 31536000 (1 year). + Set this independently so service apps never need manual + re-authentication while human users still get short sessions. auth_fingerprint: Short hash of the current auth credentials (produced by :func:`auth_fingerprint`). Mixed into the signing key so that @@ -186,6 +193,7 @@ def __init__( raw = f"{raw}:{auth_fingerprint}" self._key: bytes = raw.encode() self._expiry = expiry_seconds + self._sdk_expiry = sdk_token_expiry self._revoked: set[str] = set() @property @@ -197,8 +205,12 @@ def create(self, username: str, platform: str = "cli") -> tuple[str, float]: """Issue a new signed token. Returns ``(token_string, expires_at_unix_timestamp)``. + + SDK service tokens (``platform="sdk"``) use ``sdk_token_expiry`` + so they can be long-lived without affecting human user sessions. """ - expires_at = time.time() + self._expiry + expiry = self._sdk_expiry if platform == "sdk" else self._expiry + expires_at = time.time() + expiry payload = { "sub": username, "exp": expires_at, diff --git a/shield/cli/client.py b/shield/cli/client.py index c16b1f6..b3025a7 100644 --- a/shield/cli/client.py +++ b/shield/cli/client.py @@ -116,12 +116,22 @@ async def me(self) -> dict[str, Any]: # ── Routes ─────────────────────────────────────────────────────────── - async def list_routes(self) -> list[dict[str, Any]]: - """GET /api/routes β€” list all registered route states.""" + async def list_routes(self, service: str | None = None) -> list[dict[str, Any]]: + """GET /api/routes β€” list all registered route states. + + Pass *service* to filter to routes belonging to one service (SDK mode). + """ async with self._make_client() as c: - resp = await c.get("/api/routes") + params = {"service": service} if service else {} + resp = await c.get("/api/routes", params=params) return cast(list[dict[str, Any]], self._check(resp)) + async def list_services(self) -> list[str]: + """GET /api/services β€” list all service names registered with this server.""" + async with self._make_client() as c: + resp = await c.get("/api/services") + return cast(list[str], self._check(resp)) + async def get_route(self, path_key: str) -> dict[str, Any]: """GET /api/routes/{path_key} β€” get state for one route.""" async with self._make_client() as c: diff --git a/shield/cli/main.py b/shield/cli/main.py index ba2ae4b..fb3db91 100644 --- a/shield/cli/main.py +++ b/shield/cli/main.py @@ -343,6 +343,13 @@ def status( None, help="Route: /path or METHOD:/path. Omit for all routes.", ), + service: str | None = typer.Option( + None, + "--service", + "-s", + envvar="SHIELD_SERVICE", + help="Filter to routes for a specific service. Falls back to SHIELD_SERVICE env var.", + ), page: int = typer.Option(1, "--page", "-p", help="Page number (when listing all routes)."), per_page: int = typer.Option(_DEFAULT_PER_PAGE, "--per-page", help="Rows per page."), ) -> None: @@ -355,17 +362,27 @@ async def _run_status() -> None: states = [await client.get_route(key)] paginated, has_prev, has_next, first_num, last_num = states, False, False, 1, 1 else: - all_states = sorted(await client.list_routes(), key=lambda x: x["path"]) + all_states = sorted(await client.list_routes(service=service), key=lambda x: x["path"]) paginated, has_prev, has_next, first_num, last_num = _paginate( all_states, page, per_page ) if not paginated: - console.print("[dim]No routes registered.[/dim]") + msg = ( + f"No routes registered for service [bold]{service}[/bold]." + if service + else "No routes registered." + ) + console.print(f"[dim]{msg}[/dim]") return + # Show Service column only when listing all services (no filter active). + show_service = not service and any(s.get("service") for s in paginated) + table = Table(box=box.ROUNDED, show_header=True, header_style="bold") table.add_column("Route", style="cyan") + if show_service: + table.add_column("Service", style="dim") table.add_column("Status") table.add_column("Reason") table.add_column("Envs") @@ -382,14 +399,25 @@ async def _run_status() -> None: except Exception: window_end = window["end"] envs = s.get("allowed_envs") or [] - table.add_row( - s["path"], + # Strip service prefix from path for display. + svc = s.get("service") or "" + raw_path = s["path"] + display_path = ( + raw_path[len(svc) + 1 :] if svc and raw_path.startswith(f"{svc}:") else raw_path + ) + row = [display_path] + if show_service: + row.append(svc or "β€”") + row += [ f"[{colour}]{s['status'].upper()}[/{colour}]", s.get("reason") or "β€”", ", ".join(envs) if envs else "β€”", window_end or "β€”", - ) + ] + table.add_row(*row) + if service: + console.print(f"[dim]Service: [bold]{service}[/bold][/dim]") console.print(table) if not route and (has_prev or has_next): _print_page_footer(page, per_page, first_num, last_num, has_prev, has_next) @@ -397,15 +425,61 @@ async def _run_status() -> None: _run(_run_status) +@cli.command("services") +def list_services_cmd() -> None: + """List all services that have registered routes with this Shield Server.""" + + async def _run_services() -> None: + client = make_client() + try: + services = await client.list_services() + except Exception: + services = [] + if not services: + console.print("[dim]No services registered.[/dim]") + return + table = Table(box=box.ROUNDED, show_header=True, header_style="bold") + table.add_column("Service", style="cyan") + for svc in services: + table.add_row(svc) + console.print(table) + + _run(_run_services) + + +@cli.command("current-service") +def current_service_cmd() -> None: + """Show the active service context (set via SHIELD_SERVICE env var).""" + import os + + svc = os.environ.get("SHIELD_SERVICE", "") + if svc: + console.print( + f"Active service: [cyan bold]{svc}[/cyan bold] [dim](from SHIELD_SERVICE)[/dim]" + ) + else: + console.print( + "[dim]No active service set.[/dim]\n" + "Set one with: [cyan]export SHIELD_SERVICE=[/cyan]" + ) + + @cli.command("enable") def enable( route: str = typer.Argument(..., help="Route: /path or METHOD:/path"), reason: str = typer.Option("", "--reason", "-r", help="Optional note for the audit log."), + service: str | None = typer.Option( + None, + "--service", + "-s", + envvar="SHIELD_SERVICE", + help="Service name (SDK multi-service mode). Falls back to SHIELD_SERVICE env var.", + ), ) -> None: """Enable a route that is in maintenance or disabled state.""" async def _run_enable() -> None: - key = _parse_route(route) + key = f"{service}:{_parse_route(route)}" if service else _parse_route(route) client = make_client() try: keys_to_apply = [key] @@ -432,11 +506,18 @@ def disable_cmd( until: str | None = typer.Option( None, "--until", help="Re-enable after duration (e.g. 2h, 30m, 1d)." ), + service: str | None = typer.Option( + None, + "--service", + "-s", + envvar="SHIELD_SERVICE", + help="Service name (SDK multi-service mode). Falls back to SHIELD_SERVICE env var.", + ), ) -> None: """Permanently disable a route (returns 503 to all callers).""" async def _run_disable() -> None: - key = _parse_route(route) + key = f"{service}:{_parse_route(route)}" if service else _parse_route(route) client = make_client() try: keys_to_apply = [key] @@ -476,11 +557,18 @@ def maintenance_cmd( reason: str = typer.Option("", "--reason", "-r", help="Maintenance reason."), start: str | None = typer.Option(None, "--start", help="Window start (ISO-8601)."), end: str | None = typer.Option(None, "--end", help="Window end (ISO-8601)."), + service: str | None = typer.Option( + None, + "--service", + "-s", + envvar="SHIELD_SERVICE", + help="Service name (SDK multi-service mode). Falls back to SHIELD_SERVICE env var.", + ), ) -> None: """Put a route into maintenance mode immediately.""" async def _run_maintenance() -> None: - key = _parse_route(route) + key = f"{service}:{_parse_route(route)}" if service else _parse_route(route) start_iso = _parse_dt(start).isoformat() if start else None end_iso = _parse_dt(end).isoformat() if end else None client = make_client() @@ -511,11 +599,18 @@ def schedule_cmd( start: str = typer.Option(..., "--start", help="Window start (ISO-8601)."), end: str = typer.Option(..., "--end", help="Window end (ISO-8601)."), reason: str = typer.Option("", "--reason", "-r", help="Maintenance reason."), + service: str | None = typer.Option( + None, + "--service", + "-s", + envvar="SHIELD_SERVICE", + help="Service name (SDK multi-service mode). Falls back to SHIELD_SERVICE env var.", + ), ) -> None: """Schedule a future maintenance window (auto-activates and deactivates).""" async def _run_schedule() -> None: - key = _parse_route(route) + key = f"{service}:{_parse_route(route)}" if service else _parse_route(route) start_dt = _parse_dt(start) end_dt = _parse_dt(end) client = make_client() diff --git a/shield/core/backends/memory.py b/shield/core/backends/memory.py index 5502191..857a69a 100644 --- a/shield/core/backends/memory.py +++ b/shield/core/backends/memory.py @@ -52,6 +52,8 @@ def __init__( self._rl_hits_by_path: defaultdict[str, list[RateLimitHit]] = defaultdict(list) # Rate limit policy store β€” keyed "METHOD:/path" β†’ policy dict. self._rl_policies: dict[str, dict[str, Any]] = {} + # Subscribers for rate limit policy changes. + self._rl_policy_subscribers: list[asyncio.Queue[dict[str, Any]]] = [] async def get_state(self, path: str) -> RouteState: """Return the current state for *path*. @@ -148,13 +150,31 @@ async def get_rate_limit_hits( async def set_rate_limit_policy( self, path: str, method: str, policy_data: dict[str, Any] ) -> None: - """Persist *policy_data* for *path*/*method*.""" - self._rl_policies[f"{method.upper()}:{path}"] = policy_data + """Persist *policy_data* for *path*/*method* and notify subscribers.""" + key = f"{method.upper()}:{path}" + self._rl_policies[key] = policy_data + event: dict[str, Any] = {"action": "set", "key": key, "policy": policy_data} + for q in self._rl_policy_subscribers: + await q.put(event) async def get_rate_limit_policies(self) -> list[dict[str, Any]]: """Return all persisted rate limit policies.""" return list(self._rl_policies.values()) async def delete_rate_limit_policy(self, path: str, method: str) -> None: - """Remove the persisted rate limit policy for *path*/*method*.""" - self._rl_policies.pop(f"{method.upper()}:{path}", None) + """Remove the persisted rate limit policy for *path*/*method* and notify subscribers.""" + key = f"{method.upper()}:{path}" + self._rl_policies.pop(key, None) + event: dict[str, Any] = {"action": "delete", "key": key} + for q in self._rl_policy_subscribers: + await q.put(event) + + async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: + """Yield rate limit policy change events as they occur.""" + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._rl_policy_subscribers.append(queue) + try: + while True: + yield await queue.get() + finally: + self._rl_policy_subscribers.remove(queue) diff --git a/shield/core/backends/server.py b/shield/core/backends/server.py new file mode 100644 index 0000000..e231f57 --- /dev/null +++ b/shield/core/backends/server.py @@ -0,0 +1,540 @@ +"""ShieldServerBackend β€” remote backend that delegates to a Shield Server. + +Route states are cached locally so ``get_state()`` never touches the +network. An SSE connection keeps the cache fresh whenever the Shield +Server broadcasts a change (enable, disable, maintenance, etc.). + +Typical usage:: + + from shield.core.backends.server import ShieldServerBackend + from shield.core.engine import ShieldEngine + + backend = ShieldServerBackend( + server_url="http://shield-server:9000", + app_id="payments-service", + token="...", # omit if server has no auth + ) + engine = ShieldEngine(backend=backend) + +Or use the higher-level :class:`~shield.sdk.ShieldSDK` which wires +everything together automatically. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from shield.core.backends.base import ShieldBackend +from shield.core.models import AuditEntry, RouteState + +logger = logging.getLogger(__name__) + + +class ShieldServerBackend(ShieldBackend): + """Backend that enforces rules from a remote Shield Server. + + All enforcement happens against a local in-process cache β€” there is + zero network overhead per request. The cache is populated on startup + via ``GET /api/routes`` and kept current by a persistent SSE + connection to ``GET /api/sdk/events``. + + Parameters + ---------- + server_url: + Base URL of the Shield Server, including any mount prefix. + Example: ``http://shield-server:9000`` or + ``http://myapp.com/shield``. + app_id: + Unique identifier for this service. Shown in the Shield Server + dashboard to group routes by application. + token: + Pre-issued bearer token for Shield Server auth. Takes priority + over ``username``/``password`` if both are provided. ``None`` + if the server has no auth configured. + username: + Shield Server username. When provided alongside ``password`` + (and no ``token``), the SDK calls ``POST /api/auth/login`` with + ``platform="sdk"`` on startup and caches the returned token for + the lifetime of the process β€” no manual token management required. + password: + Shield Server password. Used with ``username`` for auto-login. + reconnect_delay: + Seconds to wait before reconnecting a dropped SSE stream. + Defaults to 5 seconds. + """ + + def __init__( + self, + server_url: str, + app_id: str, + token: str | None = None, + username: str | None = None, + password: str | None = None, + reconnect_delay: float = 5.0, + ) -> None: + self._base_url = server_url.rstrip("/") + self._app_id = app_id + self._token = token + self._username = username + self._password = password + self._reconnect_delay = reconnect_delay + + # In-process cache β€” get_state() reads only from here. + self._cache: dict[str, RouteState] = {} + + # Routes registered locally during startup before the HTTP client + # exists. Flushed to the server once startup() completes. + self._pending: list[RouteState] = [] + + # Local rate limit policy cache β€” keyed "METHOD:local_path" β†’ policy dict. + self._rl_policy_cache: dict[str, dict[str, Any]] = {} + self._rl_policy_subscribers: list[asyncio.Queue[dict[str, Any]]] = [] + + self._client: httpx.AsyncClient | None = None + self._sse_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @property + def _headers(self) -> dict[str, str]: + h: dict[str, str] = {"X-Shield-App-Id": self._app_id} + if self._token: + h["X-Shield-Token"] = self._token + return h + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def startup(self) -> None: + """Connect to the Shield Server, sync route states, start SSE listener.""" + self._client = httpx.AsyncClient( + base_url=self._base_url, + headers=self._headers, + timeout=httpx.Timeout(30.0), + ) + # Auto-login: if credentials supplied but no pre-issued token, obtain + # an SDK-platform token now so the service never needs manual auth. + if not self._token and self._username and self._password: + await self._auto_login() + # Re-create the client with the freshly obtained token in headers. + await self._client.aclose() + self._client = httpx.AsyncClient( + base_url=self._base_url, + headers=self._headers, + timeout=httpx.Timeout(30.0), + ) + await self._sync_from_server() + self._sse_task = asyncio.create_task( + self._sse_loop(), + name=f"shield-server-backend-sse[{self._app_id}]", + ) + + async def shutdown(self) -> None: + """Cancel SSE listener and close the HTTP client.""" + if self._sse_task is not None: + self._sse_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._sse_task + self._sse_task = None + if self._client is not None: + await self._client.aclose() + self._client = None + + # ------------------------------------------------------------------ + # Server sync + async def _auto_login(self) -> None: + """Exchange credentials for an SDK-platform token on startup. + + The token is stored in ``self._token`` so subsequent requests + include the ``X-Shield-Token`` header automatically. Logs a + warning (does not raise) if the login fails so the service starts + in fail-open mode rather than crashing. + """ + assert self._client is not None + try: + resp = await self._client.post( + "/api/auth/login", + json={ + "username": self._username, + "password": self._password, + "platform": "sdk", + }, + ) + if resp.status_code == 200: + self._token = resp.json().get("token") + logger.info("ShieldServerBackend[%s]: auto-login succeeded", self._app_id) + else: + logger.warning( + "ShieldServerBackend[%s]: auto-login failed (%s) β€” " + "proceeding unauthenticated (fail-open)", + self._app_id, + resp.status_code, + ) + except Exception: + logger.warning( + "ShieldServerBackend[%s]: auto-login request failed β€” " + "proceeding unauthenticated (fail-open)", + self._app_id, + exc_info=True, + ) + + # ------------------------------------------------------------------ + + async def _sync_from_server(self) -> None: + """Pull all current route states and RL policies from the Shield Server. + + Fail-open: a failed sync logs a warning and leaves the cache + empty. Requests still flow through (no state = active by default + via the engine's fail-open behaviour). + """ + assert self._client is not None + try: + resp = await self._client.get("/api/routes", params={"service": self._app_id}) + resp.raise_for_status() + for state_dict in resp.json(): + state = RouteState.model_validate(state_dict) + local_key = self._local_path(state) + self._cache[local_key] = state + logger.info( + "ShieldServerBackend[%s]: synced %d route(s) from %s", + self._app_id, + len(self._cache), + self._base_url, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "ShieldServerBackend[%s]: initial sync from %s failed β€” " + "starting with empty cache (%s). Requests will pass through.", + self._app_id, + self._base_url, + exc, + ) + + # Also sync rate limit policies so they are available immediately. + try: + resp = await self._client.get("/api/rate-limits") + resp.raise_for_status() + for policy_dict in resp.json(): + path = policy_dict.get("path", "") + method = policy_dict.get("method", "GET") + key = f"{method.upper()}:{path}" + self._rl_policy_cache[key] = policy_dict + except Exception as exc: # noqa: BLE001 + logger.debug( + "ShieldServerBackend[%s]: RL policy sync failed (non-fatal): %s", + self._app_id, + exc, + ) + + async def _flush_pending(self) -> None: + """Push locally-registered new routes to the Shield Server as one batch. + + Called by :class:`~shield.sdk.ShieldSDK` after route discovery at + startup so the dashboard reflects the service's routes. Routes + already present on the server are left untouched (server-wins). + """ + if not self._pending or self._client is None: + return + batch = self._pending[:] + self._pending.clear() + try: + await self._client.post( + "/api/sdk/register", + json={ + "app_id": self._app_id, + "states": [s.model_dump(mode="json") for s in batch], + }, + ) + logger.debug( + "ShieldServerBackend[%s]: registered %d new route(s) with server", + self._app_id, + len(batch), + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "ShieldServerBackend[%s]: failed to register %d route(s) with server: %s", + self._app_id, + len(batch), + exc, + ) + + # ------------------------------------------------------------------ + # SSE listener + # ------------------------------------------------------------------ + + async def _sse_loop(self) -> None: + """Maintain the SSE connection; reconnect automatically on any drop.""" + while True: + try: + await self._listen_sse() + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.warning( + "ShieldServerBackend[%s]: SSE disconnected (%s), reconnecting in %.1fs", + self._app_id, + exc, + self._reconnect_delay, + ) + await asyncio.sleep(self._reconnect_delay) + # Re-sync the full state after reconnecting in case we + # missed updates while the connection was down. + await self._sync_from_server() + + @staticmethod + def _local_path(state: RouteState) -> str: + """Return the plain local path used as the enforcement cache key. + + Routes registered by this SDK are stored on the Shield Server with + a service-prefixed path (``"payments-service:/api/payments"``). + Strip the prefix so ``engine.check("/api/payments")`` resolves + correctly against the local cache. + """ + if state.service and state.path.startswith(f"{state.service}:"): + return state.path[len(state.service) + 1 :] + return state.path + + async def _listen_sse(self) -> None: + """Connect to /api/sdk/events and update caches on each typed JSON event.""" + import json as _json + + if self._client is None: + return + async with self._client.stream("GET", "/api/sdk/events") as resp: + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + data = line[5:].strip() + if not data: + continue + try: + envelope = _json.loads(data) + except Exception as exc: # noqa: BLE001 + logger.debug( + "ShieldServerBackend[%s]: failed to parse SSE payload: %s", + self._app_id, + exc, + ) + continue + + event_type = envelope.get("type") if isinstance(envelope, dict) else None + + if event_type == "state": + # Typed state envelope from a new-format server. + payload = envelope.get("payload", {}) + try: + state = RouteState.model_validate(payload) + except Exception: + continue + if state.service and state.service != self._app_id: + continue + local_key = self._local_path(state) + self._cache[local_key] = state + logger.debug( + "ShieldServerBackend[%s]: cache updated β€” %s β†’ %s", + self._app_id, + local_key, + state.status, + ) + + elif event_type == "rl_policy": + # Rate limit policy change. + action = envelope.get("action") + key = envelope.get("key", "") # "METHOD:local_path" + if action == "set": + policy = envelope.get("policy", {}) + self._rl_policy_cache[key] = policy + event: dict[str, Any] = {"action": "set", "key": key, "policy": policy} + for q in self._rl_policy_subscribers: + q.put_nowait(event) + logger.debug( + "ShieldServerBackend[%s]: RL policy set β€” %s", self._app_id, key + ) + elif action == "delete": + self._rl_policy_cache.pop(key, None) + del_event: dict[str, Any] = {"action": "delete", "key": key} + for q in self._rl_policy_subscribers: + q.put_nowait(del_event) + logger.debug( + "ShieldServerBackend[%s]: RL policy deleted β€” %s", self._app_id, key + ) + + else: + # Legacy plain-RouteState payload (old server without typed envelopes). + try: + state = RouteState.model_validate(envelope) + if state.service and state.service != self._app_id: + continue + local_key = self._local_path(state) + self._cache[local_key] = state + except Exception: + pass + + # ------------------------------------------------------------------ + # ShieldBackend ABC β€” core state operations + # ------------------------------------------------------------------ + + async def get_state(self, path: str) -> RouteState: + """Return cached state β€” zero network hop.""" + try: + return self._cache[path] + except KeyError: + raise KeyError(f"No state registered for {path!r}") from None + + async def set_state(self, path: str, state: RouteState) -> None: + """Update local cache immediately; push to Shield Server asynchronously. + + Sets ``state.service`` to this SDK's ``app_id`` and stores with + the service-prefixed path on the Shield Server so routes from + different services never collide. The local cache always uses the + plain path so ``engine.check()`` resolves correctly. + + During startup (before the HTTP client exists) the state is queued + in ``_pending`` and flushed via :meth:`_flush_pending` once the + client is ready. At runtime, a background task fires the push + without blocking the caller. + """ + # Tag with this service and build the namespaced server-side path. + state = state.model_copy( + update={ + "service": self._app_id, + "path": f"{self._app_id}:{path}", + } + ) + # Local cache always uses the plain path for zero-overhead enforcement. + self._cache[path] = state + if self._client is None: + self._pending.append(state) + else: + asyncio.create_task(self._push_state(state)) + + async def _push_state(self, state: RouteState) -> None: + if self._client is None: + return + try: + await self._client.post( + "/api/sdk/register", + json={ + "app_id": self._app_id, + "states": [state.model_dump(mode="json")], + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug( + "ShieldServerBackend[%s]: failed to push state for %s: %s", + self._app_id, + state.path, + exc, + ) + + async def delete_state(self, path: str) -> None: + self._cache.pop(path, None) + + async def list_states(self) -> list[RouteState]: + return list(self._cache.values()) + + # ------------------------------------------------------------------ + # Audit log + # ------------------------------------------------------------------ + + async def write_audit(self, entry: AuditEntry) -> None: + """Forward audit entry to the Shield Server (fire-and-forget).""" + if self._client is not None: + asyncio.create_task(self._push_audit(entry)) + + async def _push_audit(self, entry: AuditEntry) -> None: + if self._client is None: + return + try: + await self._client.post( + "/api/sdk/audit", + json=entry.model_dump(mode="json"), + ) + except Exception as exc: # noqa: BLE001 + logger.debug( + "ShieldServerBackend[%s]: failed to push audit entry: %s", + self._app_id, + exc, + ) + + async def get_audit_log( + self, + path: str | None = None, + limit: int = 100, + ) -> list[AuditEntry]: + """Fetch audit log from the Shield Server.""" + if self._client is None: + return [] + try: + params: dict[str, Any] = {"limit": limit} + if path: + params["route"] = path + resp = await self._client.get("/api/audit", params=params) + resp.raise_for_status() + return [AuditEntry.model_validate(e) for e in resp.json()] + except Exception as exc: # noqa: BLE001 + logger.warning( + "ShieldServerBackend[%s]: failed to fetch audit log: %s", + self._app_id, + exc, + ) + return [] + + # ------------------------------------------------------------------ + # subscribe() β€” not needed; SDK uses an internal SSE connection + # ------------------------------------------------------------------ + + async def subscribe(self) -> AsyncIterator[RouteState]: + """Not supported β€” ``ShieldServerBackend`` manages its own SSE stream. + + The Shield Server's ``/api/sdk/events`` endpoint is consumed + internally by :meth:`_listen_sse`. Callers (e.g. the dashboard) + should use the server's own ``/events`` endpoint instead. + """ + raise NotImplementedError( + "ShieldServerBackend manages its own SSE connection internally. " + "Connect directly to the Shield Server's /events endpoint for live updates." + ) + yield # pragma: no cover β€” makes this a valid async generator + + # ------------------------------------------------------------------ + # Rate limit policy β€” local cache, updated via SSE + # ------------------------------------------------------------------ + + async def set_rate_limit_policy( + self, path: str, method: str, policy_data: dict[str, Any] + ) -> None: + """Update local RL policy cache (actual storage lives on the Shield Server).""" + key = f"{method.upper()}:{path}" + self._rl_policy_cache[key] = policy_data + + async def get_rate_limit_policies(self) -> list[dict[str, Any]]: + """Return all RL policies known to this SDK instance.""" + return list(self._rl_policy_cache.values()) + + async def delete_rate_limit_policy(self, path: str, method: str) -> None: + """Remove an RL policy from local cache.""" + self._rl_policy_cache.pop(f"{method.upper()}:{path}", None) + + async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: + """Yield rate limit policy change events pushed via the SSE connection. + + Events have the same shape as ``MemoryBackend.subscribe_rate_limit_policy()``: + ``{"action": "set/delete", "key": "METHOD:path", "policy": {...}}`` + """ + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._rl_policy_subscribers.append(queue) + try: + while True: + yield await queue.get() + finally: + self._rl_policy_subscribers.remove(queue) diff --git a/shield/core/engine.py b/shield/core/engine.py index e371dc8..d1d9851 100644 --- a/shield/core/engine.py +++ b/shield/core/engine.py @@ -1410,6 +1410,27 @@ async def reset_rate_limit( platform=platform, ) + @staticmethod + def _validate_limit_string(limit: str) -> None: + """Raise ``ValueError`` with a helpful message if *limit* is not a valid rate limit string. + + Uses the ``limits`` library's own parser so the check is authoritative. + Only called when the library is installed; silently skips otherwise. + """ + try: + from limits import parse as _parse + except ImportError: + return # limits not installed yet; engine._ensure_rate_limiter will handle it + try: + _parse(limit) + except ValueError: + valid = "second, minute, hour, day, month, year (or their plurals)" + raise ValueError( + f"Invalid rate limit string {limit!r}. " + f"Use the format '/', e.g. '100/minute'. " + f"Valid granularities: {valid}." + ) from None + async def set_rate_limit_policy( self, path: str, @@ -1429,7 +1450,25 @@ async def set_rate_limit_policy( so it takes effect immediately without a restart. Returns the ``RateLimitPolicy`` instance. + + Raises + ------ + ValueError + When *limit* is not a valid rate limit string (e.g. ``"100/minutesedrr"``). + RouteNotFoundException + When *path* is not registered in the backend. Rate limit policies + are only meaningful for routes that actually exist β€” applying one to + an unknown path would create a phantom entry that never fires. """ + self._validate_limit_string(limit) + # Guard: verify the route is registered before creating a policy. + # AmbiguousRouteError means the path exists under several HTTP methods, + # which is perfectly valid for a per-path rate limit. + try: + await self._resolve_existing(path) + except AmbiguousRouteError: + pass # route exists β€” just registered under multiple methods + from shield.core.rate_limit.models import ( RateLimitAlgorithm, RateLimitKeyStrategy, @@ -1564,6 +1603,7 @@ async def set_global_rate_limit( Returns the ``GlobalRateLimitPolicy`` instance. """ + self._validate_limit_string(limit) from shield.core.rate_limit.models import ( GlobalRateLimitPolicy, OnMissingKey, @@ -1744,10 +1784,18 @@ async def _audit( platform: str = "system", ) -> None: """Write an audit entry for a route state change.""" + # Carry the service label forward so audit rows can be filtered by service. + service: str | None = None + try: + state = await self.backend.get_state(path) + service = state.service + except Exception: # noqa: BLE001 + pass entry = AuditEntry( id=str(uuid.uuid4()), timestamp=datetime.now(UTC), path=path, + service=service, action=action, actor=actor, platform=platform, diff --git a/shield/core/models.py b/shield/core/models.py index 103bb1a..e39995b 100644 --- a/shield/core/models.py +++ b/shield/core/models.py @@ -30,6 +30,7 @@ class RouteState(BaseModel): """Full lifecycle state for a single route.""" path: str + service: str | None = None # set by ShieldSDK to group routes by service name status: RouteStatus = RouteStatus.ACTIVE reason: str = "" allowed_envs: list[str] = Field(default_factory=list) @@ -90,6 +91,7 @@ class AuditEntry(BaseModel): id: str # uuid4 timestamp: datetime path: str + service: str | None = None # mirrors RouteState.service for filtering action: str actor: str = "system" platform: str = "system" # "cli", "dashboard", or "system" diff --git a/shield/core/rate_limit/storage.py b/shield/core/rate_limit/storage.py index e577cf0..443e409 100644 --- a/shield/core/rate_limit/storage.py +++ b/shield/core/rate_limit/storage.py @@ -665,23 +665,8 @@ def create_rate_limit_storage( from shield.core.backends.redis import RedisBackend if isinstance(effective, RedisBackend): - # Extract the Redis URL from the connection pool. - pool = getattr(effective, "_pool", None) - url: str = ( - pool.connection_kwargs.get("host", "redis://localhost") - if pool - else "redis://localhost" - ) - # Prefer the full URL from pool kwargs if available. - connection_kwargs = pool.connection_kwargs if pool else {} - host = connection_kwargs.get("host", "localhost") - port = connection_kwargs.get("port", 6379) - db = connection_kwargs.get("db", 0) - password = connection_kwargs.get("password") - if password: - url = f"redis://:{password}@{host}:{port}/{db}" - else: - url = f"redis://{host}:{port}/{db}" + # RedisBackend stores the original URL in self._url. + url: str = getattr(effective, "_url", "redis://localhost:6379/0") return RedisRateLimitStorage(redis_url=url) except ImportError: pass diff --git a/shield/dashboard/app.py b/shield/dashboard/app.py index 6adeffc..0160f6d 100644 --- a/shield/dashboard/app.py +++ b/shield/dashboard/app.py @@ -65,6 +65,24 @@ def ShieldDashboard( templates.env.filters["encode_path"] = lambda p: ( base64.urlsafe_b64encode(p.encode()).decode().rstrip("=") ) + + def _clean_path(state: object) -> str: + svc = getattr(state, "service", None) + raw = getattr(state, "path", "") + if svc and raw.startswith(f"{svc}:"): + return raw[len(svc) + 1 :] + return raw + + def _clean_entry_path(entry: object) -> str: + svc = getattr(entry, "service", None) + raw = getattr(entry, "path", "") + if svc and raw.startswith(f"{svc}:"): + return raw[len(svc) + 1 :] + return raw + + templates.env.filters["clean_path"] = _clean_path + templates.env.filters["clean_entry_path"] = _clean_entry_path + # Expose path_slug as a global so templates can call it without import. templates.env.globals["path_slug"] = r.path_slug diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py index b1bd7b1..b353ec9 100644 --- a/shield/dashboard/routes.py +++ b/shield/dashboard/routes.py @@ -143,6 +143,34 @@ def _render_route_row(tpl: Jinja2Templates, state: RouteState, prefix: str) -> s # --------------------------------------------------------------------------- +def _get_services(states: list[RouteState]) -> list[str]: + return sorted({s.service for s in states if s.service}) + + +def _get_unrated_routes( + states: list[RouteState], + policies_dict: dict[str, Any], + service: str = "", +) -> list[RouteState]: + """Return route states that have no rate limit policy set. + + Strips service prefix when comparing against policy paths so that SDK + routes (stored as ``service:/path``) are matched correctly. + """ + # Policy keys are "METHOD:/path"; extract just the path portion. + rated_paths = {k.split(":", 1)[1] for k in policies_dict.keys()} + result = [] + for state in states: + if service and state.service != service: + continue + svc = state.service or "" + raw = state.path + display_path = raw[len(svc) + 1 :] if svc and raw.startswith(f"{svc}:") else raw + if display_path not in rated_paths: + result.append(state) + return sorted(result, key=lambda s: s.path) + + async def index(request: Request) -> Response: """Render the main routes page (full page).""" engine = _engine(request) @@ -150,7 +178,11 @@ async def index(request: Request) -> Response: prefix = _prefix(request) page = int(request.query_params.get("page", 1)) + service = request.query_params.get("service", "") states = await engine.list_states() + services = _get_services(states) + if service: + states = [s for s in states if s.service == service] global_config = await engine.get_global_maintenance() # Build a path β†’ policy dict for the rate limit badge column. # Policies are keyed "METHOD:/path" so we index by path only (first match wins). @@ -173,6 +205,8 @@ async def index(request: Request) -> Response: "version": request.app.state.version, "path_slug": path_slug, "shield_actor": _actor(request), + "services": services, + "selected_service": service, }, ) @@ -183,7 +217,10 @@ async def routes_partial(request: Request) -> Response: tpl = _templates(request) prefix = _prefix(request) + service = request.query_params.get("service", "") states = await engine.list_states() + if service: + states = [s for s in states if s.service == service] return tpl.TemplateResponse( request, "partials/routes_table.html", @@ -191,6 +228,7 @@ async def routes_partial(request: Request) -> Response: "states": states, "prefix": prefix, "path_slug": path_slug, + "selected_service": service, }, ) @@ -316,7 +354,12 @@ async def audit_page(request: Request) -> Response: prefix = _prefix(request) page = int(request.query_params.get("page", 1)) + service = request.query_params.get("service", "") + all_states = await engine.list_states() + services = _get_services(all_states) entries = await engine.get_audit_log(limit=1000) + if service: + entries = [e for e in entries if e.service == service] paged = _paginate(entries, page) return tpl.TemplateResponse( request, @@ -328,6 +371,8 @@ async def audit_page(request: Request) -> Response: "active_tab": "audit", "version": request.app.state.version, "shield_actor": _actor(request), + "services": services, + "selected_service": service, }, ) @@ -337,11 +382,14 @@ async def audit_rows(request: Request) -> Response: engine = _engine(request) tpl = _templates(request) + service = request.query_params.get("service", "") entries = await engine.get_audit_log(limit=50) + if service: + entries = [e for e in entries if e.service == service] return tpl.TemplateResponse( request, "partials/audit_rows.html", - {"entries": entries}, + {"entries": entries, "selected_service": service}, ) @@ -500,9 +548,20 @@ async def rate_limits_page(request: Request) -> Response: prefix = _prefix(request) page = int(request.query_params.get("page", 1)) + service = request.query_params.get("service", "") + states = await engine.list_states() + services = _get_services(states) + svc_paths = { + s.path[len(s.service) + 1 :] if s.service and s.path.startswith(s.service + ":") else s.path + for s in states + if not service or s.service == service + } policies = list(engine._rate_limit_policies.values()) + if service: + policies = [p for p in policies if p.path in svc_paths] paged = _paginate(policies, page) global_rl = await engine.get_global_rate_limit() + unrated_routes = _get_unrated_routes(states, engine._rate_limit_policies, service) return tpl.TemplateResponse( request, "rate_limits.html", @@ -514,6 +573,9 @@ async def rate_limits_page(request: Request) -> Response: "active_tab": "rate_limits", "version": request.app.state.version, "shield_actor": _actor(request), + "services": services, + "selected_service": service, + "unrated_routes": unrated_routes, }, ) @@ -525,7 +587,17 @@ async def rl_hits_page(request: Request) -> Response: prefix = _prefix(request) page = int(request.query_params.get("page", 1)) + service = request.query_params.get("service", "") + states = await engine.list_states() + services = _get_services(states) + svc_paths = { + s.path[len(s.service) + 1 :] if s.service and s.path.startswith(s.service + ":") else s.path + for s in states + if not service or s.service == service + } hits = await engine.get_rate_limit_hits(limit=10_000) + if service: + hits = [h for h in hits if h.path in svc_paths] paged = _paginate(hits, page) return tpl.TemplateResponse( request, @@ -537,6 +609,8 @@ async def rl_hits_page(request: Request) -> Response: "active_tab": "rl_hits", "version": request.app.state.version, "shield_actor": _actor(request), + "services": services, + "selected_service": service, }, ) @@ -548,21 +622,38 @@ async def rate_limits_rows_partial(request: Request) -> Response: prefix = _prefix(request) page = int(request.query_params.get("page", 1)) - policies = list(engine._rate_limit_policies.values()) + service = request.query_params.get("service", "") + if service: + states = await engine.list_states() + svc_paths = { + s.path[len(s.service) + 1 :] + if s.service and s.path.startswith(f"{s.service}:") + else s.path # noqa: E501 + for s in states + if s.service == service + } + policies = [p for p in engine._rate_limit_policies.values() if p.path in svc_paths] + else: + policies = list(engine._rate_limit_policies.values()) paged = _paginate(policies, page) return tpl.TemplateResponse( request, "partials/rate_limit_rows.html", - {"policies": paged["items"], "prefix": prefix}, + {"policies": paged["items"], "prefix": prefix, "selected_service": service}, ) def _render_rl_row(tpl: Jinja2Templates, policy: Any, prefix: str) -> str: - """Render the rate_limit_rows.html partial for a single policy.""" - return tpl.env.get_template("partials/rate_limit_rows.html").render( + """Render the rate_limit_rows.html partial for a single policy. + + Appends a tiny inline script that closes the edit modal so the modal + close fires only on a successful save (not on validation errors). + """ + html = tpl.env.get_template("partials/rate_limit_rows.html").render( policies=[policy], prefix=prefix, ) + return html + "" # ------------------------------------------------------------------ @@ -607,6 +698,86 @@ async def modal_rl_edit(request: Request) -> HTMLResponse: return HTMLResponse(html) +async def modal_rl_add(request: Request) -> HTMLResponse: + """Return the add-policy modal for a route that has no rate limit yet.""" + tpl = _templates(request) + prefix = _prefix(request) + route_path = _decode_path(request.path_params["path_key"]) + selected_service = request.query_params.get("service", "") + + # Extract the HTTP method prefix (e.g. "GET:/api/pay" β†’ method="GET", path="/api/pay"). + _http_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} + if ":" in route_path: + candidate, _, bare_path = route_path.partition(":") + if candidate.upper() in _http_methods: + method = candidate.upper() + else: + method = "" + bare_path = route_path + else: + method = "" + bare_path = route_path + + html = tpl.env.get_template("partials/modal_rl_add.html").render( + route_path=bare_path, + route_method=method, + prefix=prefix, + selected_service=selected_service, + ) + return HTMLResponse(html) + + +async def rl_add(request: Request) -> Response: + """POST /rl/add β€” create a new rate limit policy from form data. + + Reads ``path``, ``method``, ``limit``, ``algorithm``, ``key_strategy``, + and ``burst`` from the form body, registers the policy, then triggers + an HTMX page redirect so both the policies table and unrated list refresh. + """ + engine = _engine(request) + prefix = _prefix(request) + form = await request.form() + path = str(form.get("path", "")).strip() + method = str(form.get("method", "GET")).strip().upper() or "GET" + limit = str(form.get("limit", "")).strip() + algorithm = str(form.get("algorithm", "sliding_window")).strip() or None + key_strategy = str(form.get("key_strategy", "ip")).strip() or None + burst = int(str(form.get("burst", 0) or 0)) + service = str(form.get("service", "")).strip() + + if path and limit: + try: + await engine.set_rate_limit_policy( + path=path, + method=method, + limit=limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + actor=_actor(request), + platform=_platform(request), + ) + except ValueError as exc: + tpl = _templates(request) + html = tpl.env.get_template("partials/modal_rl_add.html").render( + route_path=path, + route_method=method, + prefix=prefix, + selected_service=service, + error=str(exc), + limit_value=limit, + algorithm_value=algorithm, + key_strategy_value=key_strategy, + ) + return HTMLResponse(html) + + qs = f"?service={service}" if service else "" + return Response( + status_code=204, + headers={"HX-Redirect": f"{prefix}/rate-limits{qs}"}, + ) + + async def modal_rl_delete(request: Request) -> HTMLResponse: """Return the delete-policy confirmation modal.""" tpl = _templates(request) @@ -660,15 +831,32 @@ async def rl_edit(request: Request) -> HTMLResponse: if policy is None: return HTMLResponse("") return HTMLResponse(_render_rl_row(tpl, policy, prefix)) - await engine.set_rate_limit_policy( - route_path, - method, - limit, - algorithm=algorithm, - key_strategy=key_strategy, - actor=_actor(request), - platform=_platform(request), - ) + try: + await engine.set_rate_limit_policy( + route_path, + method, + limit, + algorithm=algorithm, + key_strategy=key_strategy, + actor=_actor(request), + platform=_platform(request), + ) + except ValueError as exc: + slug = path_slug(composite) + html = tpl.env.get_template("partials/modal_rl_edit.html").render( + method=method, + route_path=route_path, + path_slug=slug, + submit_path=f"{prefix}/rl/edit/{request.path_params['path_key']}", + current_limit=limit, + current_algorithm=algorithm, + current_key_strategy=key_strategy, + error=str(exc), + ) + return HTMLResponse( + html, + headers={"HX-Retarget": "#shield-modal", "HX-Reswap": "innerHTML"}, + ) policy = engine._rate_limit_policies.get(composite) if policy is None: return HTMLResponse("") @@ -792,11 +980,22 @@ async def rate_limits_hits_partial(request: Request) -> Response: engine = _engine(request) tpl = _templates(request) + service = request.query_params.get("service", "") hits = await engine.get_rate_limit_hits(limit=50) + if service: + states = await engine.list_states() + svc_paths = { + s.path[len(s.service) + 1 :] + if s.service and s.path.startswith(f"{s.service}:") + else s.path # noqa: E501 + for s in states + if s.service == service + } + hits = [h for h in hits if h.path in svc_paths] return tpl.TemplateResponse( request, "partials/rate_limit_hits.html", - {"hits": hits}, + {"hits": hits, "selected_service": service}, ) @@ -820,10 +1019,13 @@ async def events(request: Request) -> StreamingResponse: engine = _engine(request) tpl = _templates(request) prefix = _prefix(request) + service = request.query_params.get("service", "") async def _generate() -> object: try: async for state in engine.backend.subscribe(): + if service and state.service != service: + continue slug = path_slug(state.path) html = _render_route_row(tpl, state, prefix) # Format as multi-line SSE data β€” each HTML line prefixed with "data: ". diff --git a/shield/dashboard/static/shield.min.css b/shield/dashboard/static/shield.min.css index d2f527b..81f415b 100644 --- a/shield/dashboard/static/shield.min.css +++ b/shield/dashboard/static/shield.min.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_3px_0_rgb\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-indigo-300\/40{--tw-ring-color:#a4b3ff66}@supports (color:color-mix(in lab, red, red)){.ring-indigo-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-indigo-300) 40%, transparent)}}.ring-indigo-600\/20{--tw-ring-color:#4f39f633}@supports (color:color-mix(in lab, red, red)){.ring-indigo-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-indigo-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-300\/40{--tw-ring-color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.ring-slate-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-slate-300) 40%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-200:hover{border-color:var(--color-indigo-200)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-sky-300:hover{border-color:var(--color-sky-300)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-50:hover{background-color:var(--color-indigo-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-indigo-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-indigo-800:active{background-color:var(--color-indigo-800)}@media (min-width:480px){.xs\:hidden{display:none}.xs\:inline{display:inline}.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:pb-3{padding-bottom:calc(var(--spacing) * 3)}}@media (min-width:48rem){.md\:table-cell{display:table-cell}}@media (min-width:64rem){.lg\:table-cell{display:table-cell}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[18rem\]{max-width:18rem}.max-w-\[22rem\]{max-width:22rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_3px_0_rgb\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-indigo-300\/40{--tw-ring-color:#a4b3ff66}@supports (color:color-mix(in lab, red, red)){.ring-indigo-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-indigo-300) 40%, transparent)}}.ring-indigo-600\/20{--tw-ring-color:#4f39f633}@supports (color:color-mix(in lab, red, red)){.ring-indigo-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-indigo-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-300\/40{--tw-ring-color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.ring-slate-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-slate-300) 40%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.first\:border-0:first-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-200:hover{border-color:var(--color-indigo-200)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-sky-300:hover{border-color:var(--color-sky-300)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-50:hover{background-color:var(--color-indigo-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-300:focus{--tw-ring-color:var(--color-indigo-300)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-indigo-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-indigo-800:active{background-color:var(--color-indigo-800)}@media (min-width:480px){.xs\:hidden{display:none}.xs\:inline{display:inline}.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:pb-3{padding-bottom:calc(var(--spacing) * 3)}}@media (min-width:48rem){.md\:table-cell{display:table-cell}}@media (min-width:64rem){.lg\:table-cell{display:table-cell}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/shield/dashboard/templates/audit.html b/shield/dashboard/templates/audit.html index 177f094..8539feb 100644 --- a/shield/dashboard/templates/audit.html +++ b/shield/dashboard/templates/audit.html @@ -14,7 +14,7 @@

Audit Log

{# Manual refresh button β€” no background polling #}
{# Live indicator #} @@ -68,7 +71,7 @@

Routes

+ sse-connect="{{ prefix }}/events{% if selected_service %}?service={{ selected_service }}{% endif %}"> diff --git a/shield/dashboard/templates/partials/audit_row.html b/shield/dashboard/templates/partials/audit_row.html index 5d0567d..ae5a372 100644 --- a/shield/dashboard/templates/partials/audit_row.html +++ b/shield/dashboard/templates/partials/audit_row.html @@ -13,7 +13,10 @@ {{ entry.timestamp.strftime("%H:%M:%S") }}
Path - {{ entry.path }} + {{ entry | clean_entry_path }} + {% if entry.service %} + {{ entry.service }} + {% endif %} diff --git a/shield/dashboard/templates/partials/modal_rl_add.html b/shield/dashboard/templates/partials/modal_rl_add.html new file mode 100644 index 0000000..6e20975 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_rl_add.html @@ -0,0 +1,120 @@ +
+ + {# ── Header ─────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Add Rate Limit Policy

+

Takes effect immediately β€” no restart required.

+
+ {% if route_method %} + + {{ route_method }} + + {% endif %} + + {{ route_path }} + +
+
+
+ + {# ── Add form ────────────────────────────────────────────────── #} +
+ + + + + + {# Validation error banner #} + {% if error %} +
+ + + +

{{ error }}

+
+ {% endif %} + + {# Limit #} +
+ + +
+ + {# Algorithm + Key Strategy side by side #} +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
diff --git a/shield/dashboard/templates/partials/modal_rl_edit.html b/shield/dashboard/templates/partials/modal_rl_edit.html index 423fe41..d0f74b2 100644 --- a/shield/dashboard/templates/partials/modal_rl_edit.html +++ b/shield/dashboard/templates/partials/modal_rl_edit.html @@ -22,8 +22,17 @@

Edit Rate Limit Policy

{# ── Edit form ───────────────────────────────────────────────── #}
+ hx-swap="outerHTML"> + + {# Validation error banner #} + {% if error %} +
+ + + +

{{ error }}

+
+ {% endif %} {# Limit #}
@@ -38,10 +47,11 @@

Edit Rate Limit Policy

autofocus autocomplete="off" placeholder="e.g. 100/minute, 1000/hour" - class="w-full text-sm border border-slate-300 bg-white rounded-lg px-3 py-2.5 text-slate-800 + class="w-full text-sm rounded-lg px-3 py-2.5 text-slate-800 font-mono placeholder:text-slate-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 - transition-shadow"> + transition-shadow + {% if error %}border-red-400 bg-red-50{% else %}border border-slate-300 bg-white{% endif %}">
{# Algorithm + Key Strategy side by side #} diff --git a/shield/dashboard/templates/partials/route_row.html b/shield/dashboard/templates/partials/route_row.html index 9fcd8aa..7de9c23 100644 --- a/shield/dashboard/templates/partials/route_row.html +++ b/shield/dashboard/templates/partials/route_row.html @@ -6,7 +6,12 @@ {# ── Path ──────────────────────────────────────────────────────── #}
- {{ state.path }} +
+ {{ state | clean_path }} + {% if state.service %} + {{ state.service }} + {% endif %} +
{% if state.force_active %} diff --git a/shield/dashboard/templates/rate_limits.html b/shield/dashboard/templates/rate_limits.html index 0323575..be0c235 100644 --- a/shield/dashboard/templates/rate_limits.html +++ b/shield/dashboard/templates/rate_limits.html @@ -7,7 +7,7 @@

Rate Limits

Active rate limit policies for registered routes.

- + @@ -50,9 +50,77 @@

Policies

No rate limit policies configured

-

Decorate routes with @rate_limit("100/minute") to add limits.

+

Use the Unprotected Routes section below or decorate routes with @rate_limit("100/minute").

{% endif %} +{# ── Unprotected routes ──────────────────────────────────────────── #} +{% if unrated_routes %} +
+
+
+ + + +

Unprotected Routes

+
+ {{ unrated_routes | length }} route{% if unrated_routes | length != 1 %}s{% endif %} without a rate limit +
+
+ + + + + + + + + + {% for state in unrated_routes %} + {% set svc = state.service or "" %} + {% set raw_path = state.path %} + {% set display_path = raw_path[svc | length + 1:] if svc and raw_path.startswith(svc + ":") else raw_path %} + {% set encoded = display_path | encode_path %} + + + + + + {% endfor %} + +
RouteStatusAction
+
+ {{ display_path }} + {% if svc %} + {{ svc }} + {% endif %} +
+
+ + {{ state.status | upper }} + + + +
+
+
+{% endif %} + {% endblock %} diff --git a/shield/sdk/__init__.py b/shield/sdk/__init__.py new file mode 100644 index 0000000..1748a3e --- /dev/null +++ b/shield/sdk/__init__.py @@ -0,0 +1,197 @@ +"""ShieldSDK β€” connect a FastAPI app to a remote Shield Server. + +Drop-in alternative to the embedded setup. State is managed centrally +from the Shield Server dashboard or CLI; this SDK enforces it locally on +every request with zero network overhead. + +Usage:: + + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield-server:9000", + app_id="payments-service", + token="...", # omit if server has no auth + ) + sdk.attach(app) + + @app.get("/payments") + @maintenance(reason="DB migration") # optional β€” manage from dashboard instead + async def payments(): + return {"ok": True} + +The CLI then points at the Shield Server, not at this service:: + + shield config set-url http://shield-server:9000 + shield status # routes from ALL connected services + shield disable payments-service /api/payments --reason "migration" +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from shield.core.backends.base import ShieldBackend +from shield.core.backends.server import ShieldServerBackend +from shield.core.engine import ShieldEngine + +if TYPE_CHECKING: + from fastapi import FastAPI + +__all__ = ["ShieldSDK"] + +logger = logging.getLogger(__name__) + + +class ShieldSDK: + """Connect a FastAPI application to a remote Shield Server. + + Parameters + ---------- + server_url: + Base URL of the Shield Server (e.g. ``http://shield-server:9000``). + If the Shield Server is mounted under a prefix (e.g. ``/shield``), + include the prefix: ``http://myapp.com/shield``. + app_id: + Unique name for this service shown in the Shield Server dashboard. + Use a stable identifier like ``"payments-service"`` or + ``"orders-api"``. + token: + Pre-issued bearer token for Shield Server auth. Takes priority + over ``username``/``password`` if both are provided. Omit if + the server has no auth configured. + username: + Shield Server username. When provided alongside ``password`` + (and no ``token``), the SDK automatically calls + ``POST /api/auth/login`` on startup with ``platform="sdk"`` and + obtains a long-lived service token β€” no manual token management + required. Store credentials in environment variables and inject + them at deploy time:: + + sdk = ShieldSDK( + server_url=os.environ["SHIELD_SERVER_URL"], + app_id="payments-service", + username=os.environ["SHIELD_USERNAME"], + password=os.environ["SHIELD_PASSWORD"], + ) + password: + Shield Server password. Used together with ``username``. + reconnect_delay: + Seconds between SSE reconnect attempts after a dropped connection. + Defaults to 5 seconds. + rate_limit_backend: + Optional shared backend for rate limit counter storage. When + ``None`` (default) each instance maintains its own in-process + counters β€” a ``100/minute`` limit is enforced independently on + each replica. Pass a :class:`~shield.core.backends.redis.RedisBackend` + pointing at a shared Redis instance to enforce the limit + **across all replicas combined**:: + + from shield.core.backends.redis import RedisBackend + + sdk = ShieldSDK( + server_url="http://shield:9000", + app_id="payments-service", + rate_limit_backend=RedisBackend(url="redis://redis:6379/1"), + ) + """ + + def __init__( + self, + server_url: str, + app_id: str, + token: str | None = None, + username: str | None = None, + password: str | None = None, + reconnect_delay: float = 5.0, + rate_limit_backend: ShieldBackend | None = None, + ) -> None: + self._backend = ShieldServerBackend( + server_url=server_url, + app_id=app_id, + token=token, + username=username, + password=password, + reconnect_delay=reconnect_delay, + ) + self._engine = ShieldEngine( + backend=self._backend, + rate_limit_backend=rate_limit_backend, + ) + + @property + def engine(self) -> ShieldEngine: + """The underlying :class:`~shield.core.engine.ShieldEngine`. + + Use this if you need direct engine access (e.g. to call + ``engine.disable()`` programmatically from within the service). + """ + return self._engine + + def attach(self, app: FastAPI) -> None: + """Wire shield middleware and lifecycle hooks into *app*. + + Call this once after creating the FastAPI app and before + defining routes:: + + sdk.attach(app) + + @app.get("/payments") + async def payments(): + ... + + What ``attach`` does: + + 1. Adds :class:`~shield.fastapi.middleware.ShieldMiddleware` so + every request is checked against the local state cache. + 2. On startup: syncs state from the Shield Server, starts the SSE + listener, discovers decorated routes, and registers any new + ones with the server. + 3. On shutdown: closes the SSE connection and HTTP client cleanly. + + Parameters + ---------- + app: + The :class:`fastapi.FastAPI` application to attach to. + """ + from fastapi.routing import APIRoute + + from shield.fastapi.middleware import ShieldMiddleware + + app.add_middleware(ShieldMiddleware, engine=self._engine) + + @app.on_event("startup") + async def _shield_sdk_startup() -> None: + # Start engine background tasks (pub/sub listeners, etc.) + await self._engine.start() + # Connect to Shield Server: sync state + open SSE stream. + await self._backend.startup() + + # Discover routes decorated with @maintenance, @disabled, etc. + # and register any that are new to the Shield Server. + shield_routes: list[tuple[str, dict[str, Any]]] = [ + (route.path, route.endpoint.__shield_meta__) + for route in app.routes + if isinstance(route, APIRoute) and hasattr(route.endpoint, "__shield_meta__") + ] + + if shield_routes: + # register_batch() is persistence-first: routes already + # present in the cache (synced from server) are skipped. + await self._engine.register_batch(shield_routes) + + # Push any truly new routes (not already on the server) in + # one HTTP round-trip. + await self._backend._flush_pending() + + logger.info( + "ShieldSDK[%s]: attached β€” %d shield route(s) discovered", + self._backend._app_id, + len(shield_routes), + ) + + @app.on_event("shutdown") + async def _shield_sdk_shutdown() -> None: + await self._backend.shutdown() + await self._engine.stop() diff --git a/shield/server/__init__.py b/shield/server/__init__.py new file mode 100644 index 0000000..aef1b5e --- /dev/null +++ b/shield/server/__init__.py @@ -0,0 +1,115 @@ +"""ShieldServer β€” standalone Shield Server factory. + +Deploy this once as its own service. All your FastAPI applications +connect to it via :class:`~shield.sdk.ShieldSDK`. + +Usage:: + + # shield_server.py + from shield.server import ShieldServer + from shield.core.backends.redis import RedisBackend + + app = ShieldServer( + backend=RedisBackend("redis://localhost:6379"), + auth=("admin", "secret"), + ) + + # Run with: uvicorn shield_server:app + +Then in each service:: + + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield-server:9000", + app_id="payments-service", + token="...", + ) + sdk.attach(app) + +And point the CLI at the server:: + + shield config set-url http://shield-server:9000 + shield login admin + shield status + +Use ``RedisBackend`` so every connected service receives live state +updates via the SSE channel. ``MemoryBackend`` works for local +development with a single service. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from starlette.types import ASGIApp + + from shield.admin.auth import AuthConfig + from shield.core.backends.base import ShieldBackend + +__all__ = ["ShieldServer"] + + +def ShieldServer( + backend: ShieldBackend, + auth: AuthConfig = None, + token_expiry: int = 86400, + sdk_token_expiry: int = 31536000, + secret_key: str | None = None, + prefix: str = "", +) -> ASGIApp: + """Create a standalone Shield Server ASGI application. + + The returned app exposes the full :class:`~shield.admin.app.ShieldAdmin` + surface: dashboard UI, REST API, and the SDK SSE/register endpoints. + + Parameters + ---------- + backend: + Storage backend. Use :class:`~shield.core.backends.redis.RedisBackend` + for multi-service deployments β€” Redis pub/sub ensures every SDK + client receives live updates when state changes. + :class:`~shield.core.backends.memory.MemoryBackend` is fine for + local development with a single service. + auth: + Credentials config β€” same as :func:`~shield.admin.app.ShieldAdmin`: + + * ``None`` β€” open access (no credentials required) + * ``("user", "pass")`` β€” single user + * ``[("u1", "p1"), ("u2", "p2")]`` β€” multiple users + * :class:`~shield.admin.auth.ShieldAuthBackend` instance β€” custom + token_expiry: + Token lifetime in seconds for dashboard and CLI users. + Default: 86400 (24 h). + sdk_token_expiry: + Token lifetime in seconds for SDK service tokens. + Default: 31536000 (1 year). Service apps that authenticate with + ``username``/``password`` via :class:`~shield.sdk.ShieldSDK` + receive a token of this duration so they never need manual + re-authentication. + secret_key: + HMAC signing key. Use a stable value so tokens survive process + restarts. Defaults to a random key (tokens invalidated on restart). + prefix: + URL prefix if the server app is mounted under a sub-path. + Usually empty when running as a standalone service. + + Returns + ------- + ASGIApp + A Starlette ASGI application ready to be served by uvicorn / + gunicorn, or mounted on another app. + """ + from shield.admin.app import ShieldAdmin + from shield.core.engine import ShieldEngine + + engine = ShieldEngine(backend=backend) + return ShieldAdmin( + engine=engine, + auth=auth, + token_expiry=token_expiry, + sdk_token_expiry=sdk_token_expiry, + secret_key=secret_key, + prefix=prefix, + ) diff --git a/tests/admin/test_api.py b/tests/admin/test_api.py index 34a7c86..7f6ac93 100644 --- a/tests/admin/test_api.py +++ b/tests/admin/test_api.py @@ -445,3 +445,56 @@ async def test_set_rate_limit_with_algorithm( ) assert resp.status_code == 201 assert resp.json()["algorithm"] == "fixed_window" + + +@_rl_mark +async def test_set_rate_limit_unknown_route_returns_404( + open_client: AsyncClient, +) -> None: + """POST /api/rate-limits for a path not registered in the engine returns 404.""" + resp = await open_client.post( + "/api/rate-limits", + json={"path": "/does/not/exist", "method": "GET", "limit": "10/minute"}, + ) + assert resp.status_code == 404 + + +@_rl_mark +async def test_set_rate_limit_registered_route_returns_201( + open_client: AsyncClient, engine: ShieldEngine +) -> None: + """POST /api/rate-limits with a path that is registered returns 201.""" + resp = await open_client.post( + "/api/rate-limits", + json={"path": "/payments", "method": "GET", "limit": "20/minute"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["path"] == "/payments" + assert body["limit"] == "20/minute" + + +@_rl_mark +async def test_set_rate_limit_for_ambiguous_route_succeeds( + open_client: AsyncClient, engine: ShieldEngine +) -> None: + """POST /api/rate-limits for a bare path that is registered under multiple + HTTP methods (ambiguous) still succeeds with 201.""" + # Seed the same path under two methods so the engine sees an ambiguous route. + from shield.core.models import RouteState, RouteStatus + + await engine.backend.set_state( + "GET:/api/items", + RouteState(path="GET:/api/items", status=RouteStatus.ACTIVE), + ) + await engine.backend.set_state( + "POST:/api/items", + RouteState(path="POST:/api/items", status=RouteStatus.ACTIVE), + ) + + resp = await open_client.post( + "/api/rate-limits", + json={"path": "/api/items", "method": "GET", "limit": "15/minute"}, + ) + assert resp.status_code == 201 + assert resp.json()["path"] == "/api/items" diff --git a/tests/core/rate_limit/test_policy_persistence.py b/tests/core/rate_limit/test_policy_persistence.py index 607df73..66a9d45 100644 --- a/tests/core/rate_limit/test_policy_persistence.py +++ b/tests/core/rate_limit/test_policy_persistence.py @@ -141,10 +141,20 @@ async def test_coexists_with_states_and_audit(self, tmp_path): class TestEngineRateLimitPersistence: + """Engine-level rate limit persistence tests. + + All tests register the route first β€” ``set_rate_limit_policy`` now + validates that the path is registered before creating a policy. + """ + + async def _register(self, engine, path: str = "/api/items") -> None: + await engine.register(path, {"status": "active"}) + async def test_set_rate_limit_policy_registers_live(self): from shield.core.engine import ShieldEngine engine = ShieldEngine() + await self._register(engine) policy = await engine.set_rate_limit_policy("/api/items", "GET", "10/minute") assert policy.limit == "10/minute" assert "GET:/api/items" in engine._rate_limit_policies @@ -153,6 +163,7 @@ async def test_set_rate_limit_policy_persisted_to_backend(self): from shield.core.engine import ShieldEngine engine = ShieldEngine() + await self._register(engine) await engine.set_rate_limit_policy("/api/items", "GET", "10/minute") policies = await engine.backend.get_rate_limit_policies() assert len(policies) == 1 @@ -162,6 +173,7 @@ async def test_delete_rate_limit_policy_removes_from_in_memory(self): from shield.core.engine import ShieldEngine engine = ShieldEngine() + await self._register(engine) await engine.set_rate_limit_policy("/api/items", "GET", "10/minute") await engine.delete_rate_limit_policy("/api/items", "GET") assert "GET:/api/items" not in engine._rate_limit_policies @@ -170,6 +182,7 @@ async def test_delete_rate_limit_policy_removes_from_backend(self): from shield.core.engine import ShieldEngine engine = ShieldEngine() + await self._register(engine) await engine.set_rate_limit_policy("/api/items", "GET", "10/minute") await engine.delete_rate_limit_policy("/api/items", "GET") policies = await engine.backend.get_rate_limit_policies() @@ -180,6 +193,7 @@ async def test_restore_rate_limit_policies_loads_from_backend(self): # Persist a policy directly to the backend (simulating a prior CLI call). engine1 = ShieldEngine() + await self._register(engine1) await engine1.set_rate_limit_policy("/api/items", "GET", "10/minute") # Simulate a new engine instance on the same backend after a restart. @@ -194,6 +208,7 @@ async def test_set_policy_with_algorithm(self): from shield.core.rate_limit.models import RateLimitAlgorithm engine = ShieldEngine() + await self._register(engine) policy = await engine.set_rate_limit_policy( "/api/items", "GET", "10/minute", algorithm="fixed_window" ) @@ -204,7 +219,17 @@ async def test_set_policy_with_key_strategy(self): from shield.core.rate_limit.models import RateLimitKeyStrategy engine = ShieldEngine() + await self._register(engine) policy = await engine.set_rate_limit_policy( "/api/items", "GET", "10/minute", key_strategy="global" ) assert policy.key_strategy == RateLimitKeyStrategy.GLOBAL + + async def test_set_policy_for_unknown_route_raises(self): + """set_rate_limit_policy must reject paths that are not registered.""" + from shield.core.engine import ShieldEngine + from shield.core.exceptions import RouteNotFoundException + + engine = ShieldEngine() + with pytest.raises(RouteNotFoundException): + await engine.set_rate_limit_policy("/does/not/exist", "GET", "10/minute") diff --git a/tests/dashboard/test_rl_dashboard.py b/tests/dashboard/test_rl_dashboard.py new file mode 100644 index 0000000..9ca5d0d --- /dev/null +++ b/tests/dashboard/test_rl_dashboard.py @@ -0,0 +1,218 @@ +"""Tests for the rate-limit dashboard features. + +Covers: +- _get_unrated_routes() unit tests +- /rate-limits page rendering (unprotected routes section) +- /modal/rl/add/{path_key} modal GET +- /rl/add POST (create policy, redirect, unknown-route error) +""" + +from __future__ import annotations + +import base64 + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.admin.app import ShieldAdmin +from shield.core.engine import ShieldEngine +from shield.core.models import RouteState, RouteStatus +from shield.core.rate_limit.storage import HAS_LIMITS +from shield.dashboard.routes import _get_unrated_routes + +# --------------------------------------------------------------------------- +# Skip marker β€” skip every rate-limit test when the limits library is absent +# --------------------------------------------------------------------------- + +pytestmark = pytest.mark.skipif(not HAS_LIMITS, reason="limits library not installed") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _encode_path(path: str) -> str: + """Base64url-encode *path* for use in a URL segment (mirrors routes.py).""" + return base64.urlsafe_b64encode(path.encode()).decode().rstrip("=") + + +def _make_state(path: str, service: str = "") -> RouteState: + """Return a minimal active RouteState for *path*.""" + return RouteState(path=path, status=RouteStatus.ACTIVE, service=service or None) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def engine() -> ShieldEngine: + """Provide a ShieldEngine pre-loaded with two test routes.""" + e = ShieldEngine() + await e.backend.set_state( + "/api/items", RouteState(path="/api/items", status=RouteStatus.ACTIVE) + ) + await e.backend.set_state( + "/api/orders", RouteState(path="/api/orders", status=RouteStatus.ACTIVE) + ) + return e + + +@pytest.fixture +def admin(engine: ShieldEngine) -> object: + """Return a ShieldAdmin ASGI app (no auth).""" + return ShieldAdmin(engine=engine) + + +@pytest.fixture +async def client(admin: object) -> AsyncClient: + """Return an httpx AsyncClient pointing at the ShieldAdmin app.""" + async with AsyncClient( + transport=ASGITransport(app=admin), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Unit tests for _get_unrated_routes +# --------------------------------------------------------------------------- + + +def test_get_unrated_routes_returns_all_when_no_policies() -> None: + """All routes are unrated when no policies exist.""" + states = [_make_state("/api/items"), _make_state("/api/orders")] + result = _get_unrated_routes(states, {}, "") + assert len(result) == 2 + paths = {s.path for s in result} + assert "/api/items" in paths + assert "/api/orders" in paths + + +def test_get_unrated_routes_excludes_rated() -> None: + """A route that has a policy is excluded from the unrated list.""" + states = [_make_state("/api/items"), _make_state("/api/orders")] + # Policy key format is "METHOD:/path" + policies = {"GET:/api/items": object()} + result = _get_unrated_routes(states, policies, "") + paths = {s.path for s in result} + assert "/api/items" not in paths + assert "/api/orders" in paths + + +def test_get_unrated_routes_service_filter() -> None: + """With service filter, only routes matching that service are included.""" + states = [ + _make_state("/api/items", service="payments"), + _make_state("/api/orders", service="fulfillment"), + _make_state("/api/users", service="payments"), + ] + result = _get_unrated_routes(states, {}, "payments") + services = {s.service for s in result} + assert services == {"payments"} + paths = {s.path for s in result} + assert "/api/orders" not in paths + + +# --------------------------------------------------------------------------- +# Integration tests β€” ShieldAdmin /rate-limits page +# --------------------------------------------------------------------------- + + +async def test_rate_limits_page_includes_unprotected_section( + client: AsyncClient, +) -> None: + """GET /rate-limits returns 200 and includes the 'Unprotected Routes' section + when routes with no rate limit policy exist.""" + resp = await client.get("/rate-limits") + assert resp.status_code == 200 + assert "Unprotected Routes" in resp.text + + +async def test_rate_limits_page_no_unprotected_when_all_rated( + engine: ShieldEngine, admin: object +) -> None: + """When every route has a policy, 'Unprotected Routes' should not appear.""" + # Add policies for both registered routes. + await engine.set_rate_limit_policy( + path="/api/items", method="GET", limit="10/minute", actor="test" + ) + await engine.set_rate_limit_policy( + path="/api/orders", method="GET", limit="10/minute", actor="test" + ) + + async with AsyncClient( + transport=ASGITransport(app=admin), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + resp = await c.get("/rate-limits") + + assert resp.status_code == 200 + assert "Unprotected Routes" not in resp.text + + +# --------------------------------------------------------------------------- +# Integration tests β€” modal GET /modal/rl/add/{path_key} +# --------------------------------------------------------------------------- + + +async def test_modal_rl_add_returns_200(client: AsyncClient) -> None: + """GET /modal/rl/add/{path_key} returns 200 with the add-policy form.""" + path_key = _encode_path("/api/items") + resp = await client.get(f"/modal/rl/add/{path_key}") + assert resp.status_code == 200 + assert "Add Rate Limit Policy" in resp.text + + +# --------------------------------------------------------------------------- +# Integration tests β€” POST /rl/add +# --------------------------------------------------------------------------- + + +async def test_rl_add_creates_policy_and_redirects(engine: ShieldEngine, admin: object) -> None: + """POST /rl/add with a registered path creates the policy and returns 204 + with an HX-Redirect header pointing at /rate-limits.""" + async with AsyncClient( + transport=ASGITransport(app=admin), # type: ignore[arg-type] + base_url="http://testserver", + follow_redirects=False, + ) as c: + resp = await c.post( + "/rl/add", + data={ + "path": "/api/items", + "method": "GET", + "limit": "10/minute", + }, + ) + + assert resp.status_code == 204 + assert "HX-Redirect" in resp.headers + assert "/rate-limits" in resp.headers["HX-Redirect"] + # Confirm the policy was actually persisted in the engine. + assert "GET:/api/items" in engine._rate_limit_policies + + +async def test_rl_add_unknown_route_returns_error(admin: object) -> None: + """POST /rl/add for a path that is not registered in the engine rejects + the request β€” the handler raises RouteNotFoundException (propagates as a + server error since rl_add does not catch it).""" + from shield.core.exceptions import RouteNotFoundException + + async with AsyncClient( + transport=ASGITransport(app=admin), # type: ignore[arg-type] + base_url="http://testserver", + follow_redirects=False, + # Allow the exception to propagate so we can assert its type. + ) as c: + with pytest.raises(RouteNotFoundException): + await c.post( + "/rl/add", + data={ + "path": "/does/not/exist", + "method": "GET", + "limit": "5/minute", + }, + ) diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py new file mode 100644 index 0000000..8c024f0 --- /dev/null +++ b/tests/test_cli_service.py @@ -0,0 +1,241 @@ +"""Tests for SHIELD_SERVICE env var fallback and shield current-service command. + +The CLI is a thin HTTP client; tests create an in-process ShieldAdmin ASGI +app and inject it into the CLI via the ``make_client`` monkeypatch, so no +real server is needed. + +IMPORTANT: Tests that call ``invoke_with_client`` must be sync (``def``, not +``async def``) because the CLI uses ``anyio.run()`` internally and that +cannot be nested inside a running pytest-asyncio event loop. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import anyio +import httpx +from typer.testing import CliRunner + +from shield.admin.app import ShieldAdmin +from shield.cli.client import ShieldClient +from shield.cli.main import cli as app +from shield.core.engine import ShieldEngine +from shield.core.models import RouteState, RouteStatus + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _seed_engine(*paths: str) -> ShieldEngine: + """Create a ShieldEngine and seed *paths* as ACTIVE routes (synchronously).""" + e = ShieldEngine() + + async def _run() -> None: + for path in paths: + await e.backend.set_state(path, RouteState(path=path, status=RouteStatus.ACTIVE)) + + anyio.run(_run) + return e + + +def _run_sync(coro_fn): + """Run a no-argument async callable synchronously and return the result.""" + results = [] + + async def _wrap(): + results.append(await coro_fn()) + + anyio.run(_wrap) + return results[0] if results else None + + +def _open_client(engine: ShieldEngine) -> ShieldClient: + """Return a ShieldClient backed by an in-process ShieldAdmin (no auth).""" + admin = ShieldAdmin(engine=engine) + return ShieldClient( + base_url="http://testserver", + transport=httpx.ASGITransport(app=admin), # type: ignore[arg-type] + ) + + +def invoke_with_client(client: ShieldClient, *args: str) -> object: + """Invoke a CLI command with *client* injected via ``make_client``.""" + with patch("shield.cli.main.make_client", return_value=client): + return runner.invoke(app, list(args), catch_exceptions=False) + + +# --------------------------------------------------------------------------- +# current-service command +# --------------------------------------------------------------------------- + + +def test_current_service_no_env(monkeypatch) -> None: + """shield current-service with no SHIELD_SERVICE set shows 'No active service'.""" + monkeypatch.delenv("SHIELD_SERVICE", raising=False) + result = runner.invoke(app, ["current-service"], catch_exceptions=False) + assert result.exit_code == 0 + assert "No active service" in result.output + + +def test_current_service_with_env(monkeypatch) -> None: + """shield current-service with SHIELD_SERVICE set shows the service name.""" + monkeypatch.setenv("SHIELD_SERVICE", "payments-service") + result = runner.invoke(app, ["current-service"], catch_exceptions=False) + assert result.exit_code == 0 + assert "payments-service" in result.output + + +# --------------------------------------------------------------------------- +# status --service (env var fallback) +# --------------------------------------------------------------------------- + + +def test_status_uses_shield_service_env(monkeypatch) -> None: + """SHIELD_SERVICE env var causes status to filter routes to that service.""" + monkeypatch.setenv("SHIELD_SERVICE", "payments-service") + + e = ShieldEngine() + + async def _seed() -> None: + state = RouteState( + path="payments-service:/api/pay", + service="payments-service", + status=RouteStatus.ACTIVE, + ) + await e.backend.set_state("payments-service:/api/pay", state) + + anyio.run(_seed) + + client = _open_client(e) + result = invoke_with_client(client, "status") + assert result.exit_code == 0 + # The status output should reference the service or the route path fragment. + assert ( + "payments-service" in result.output or "/api/pay" in result.output or "api" in result.output + ) + + +# --------------------------------------------------------------------------- +# enable β€” SHIELD_SERVICE env var builds composite key +# --------------------------------------------------------------------------- + + +def test_enable_uses_shield_service_env(monkeypatch) -> None: + """shield enable /api/pay uses SHIELD_SERVICE to form composite key.""" + monkeypatch.setenv("SHIELD_SERVICE", "payments-service") + + e = ShieldEngine() + + async def _seed() -> None: + # Seed as DISABLED so enable has something to act on. + state = RouteState( + path="payments-service:/api/pay", + service="payments-service", + status=RouteStatus.DISABLED, + ) + await e.backend.set_state("payments-service:/api/pay", state) + + anyio.run(_seed) + + client = _open_client(e) + result = invoke_with_client(client, "enable", "/api/pay") + assert result.exit_code == 0 + # The composite key should appear in the output. + assert "payments-service:/api/pay" in result.output + + +# --------------------------------------------------------------------------- +# disable β€” SHIELD_SERVICE env var builds composite key +# --------------------------------------------------------------------------- + + +def test_disable_uses_shield_service_env(monkeypatch) -> None: + """shield disable /api/pay uses SHIELD_SERVICE to form composite key.""" + monkeypatch.setenv("SHIELD_SERVICE", "payments-service") + + e = ShieldEngine() + + async def _seed() -> None: + state = RouteState( + path="payments-service:/api/pay", + service="payments-service", + status=RouteStatus.ACTIVE, + ) + await e.backend.set_state("payments-service:/api/pay", state) + + anyio.run(_seed) + + client = _open_client(e) + result = invoke_with_client(client, "disable", "/api/pay", "--reason", "testing") + assert result.exit_code == 0 + assert "payments-service:/api/pay" in result.output + + +# --------------------------------------------------------------------------- +# maintenance β€” SHIELD_SERVICE env var builds composite key +# --------------------------------------------------------------------------- + + +def test_maintenance_uses_shield_service_env(monkeypatch) -> None: + """shield maintenance /api/pay uses SHIELD_SERVICE to form composite key.""" + monkeypatch.setenv("SHIELD_SERVICE", "payments-service") + + e = ShieldEngine() + + async def _seed() -> None: + state = RouteState( + path="payments-service:/api/pay", + service="payments-service", + status=RouteStatus.ACTIVE, + ) + await e.backend.set_state("payments-service:/api/pay", state) + + anyio.run(_seed) + + client = _open_client(e) + result = invoke_with_client(client, "maintenance", "/api/pay", "--reason", "swap") + assert result.exit_code == 0 + assert "payments-service:/api/pay" in result.output + + +# --------------------------------------------------------------------------- +# --service flag overrides SHIELD_SERVICE env var +# --------------------------------------------------------------------------- + + +def test_service_flag_overrides_env_var(monkeypatch) -> None: + """Explicit --service=orders-service takes priority over SHIELD_SERVICE=payments-service.""" + monkeypatch.setenv("SHIELD_SERVICE", "payments-service") + + e = ShieldEngine() + + async def _seed() -> None: + # Seed the orders-service route as DISABLED so enable works. + orders_state = RouteState( + path="orders-service:/api/pay", + service="orders-service", + status=RouteStatus.DISABLED, + ) + await e.backend.set_state("orders-service:/api/pay", orders_state) + + # Also seed the payments-service route to confirm it is NOT used. + payments_state = RouteState( + path="payments-service:/api/pay", + service="payments-service", + status=RouteStatus.DISABLED, + ) + await e.backend.set_state("payments-service:/api/pay", payments_state) + + anyio.run(_seed) + + client = _open_client(e) + result = invoke_with_client(client, "enable", "/api/pay", "--service", "orders-service") + assert result.exit_code == 0 + # The orders-service composite key must appear; payments-service must not. + assert "orders-service:/api/pay" in result.output + assert "payments-service:/api/pay" not in result.output diff --git a/uv.lock b/uv.lock index 69e51ce..37bca9d 100644 --- a/uv.lock +++ b/uv.lock @@ -198,11 +198,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] [[package]] @@ -817,7 +817,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.5" +version = "9.7.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -832,9 +832,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/76/5c202fecdc45d53e83e03a85bae70c48b6c81e9f87f0bc19a9e9c723bdc0/mkdocs_material-9.7.5.tar.gz", hash = "sha256:f76bdab532bad1d9c57ca7187b37eccf64dd12e1586909307f8856db3be384ea", size = 4097749 } +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959 } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/e1/e8080dcfa95cca267662a6f4afe29237452bdeb5a2a6555ac83646d21915/mkdocs_material-9.7.5-py3-none-any.whl", hash = "sha256:7cf9df2ff121fd098ff6e05c732b0be3699afca9642e2dfe4926c40eb5873eec", size = 9305251 }, + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470 }, ] [[package]] @@ -1319,27 +1319,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953 }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257 }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683 }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986 }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177 }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783 }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201 }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561 }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928 }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186 }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231 }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357 }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583 }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976 }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872 }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271 }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497 }, +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037 }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433 }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302 }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625 }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743 }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536 }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292 }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981 }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422 }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158 }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861 }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310 }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752 }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961 }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538 }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839 }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304 }, ] [[package]]