diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a67d7a..19fdc6d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,5 +135,5 @@ These are hard constraints enforced by the project design. PRs that violate them 2. **All business logic lives in `ShieldEngine`.** Middleware and decorators are transport layers only. 3. **Decorators stamp `__shield_meta__` and do nothing else** — no logic at request time. 4. **`engine.check()` is the single chokepoint** — never duplicate the check logic elsewhere. -5. **Backends must implement the full `ShieldBackend` ABC** — no partial implementations. +5. **Backends must implement the full `ShieldBackend` ABC** — no partial implementations. If a method is not supported (e.g. `subscribe()` on `FileBackend`), it raises `NotImplementedError`. `ShieldEngine.start()` catches this internally and skips the listener — the engine handles the fallback, not the caller. 6. **Fail-open** — if the backend is unreachable, the request passes through. Shield never takes down an API. diff --git a/README.md b/README.md index b2de328..d46f4e0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
API Shield -

Route(API) lifecycle management for ASGI Python web frameworks — maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.

+

Feature flags and runtime control for Python APIs — rollouts, rate limits, manage maintenance windows across single ASGI services or a multi-service fleet without redeploying.

PyPI Python versions @@ -33,6 +33,9 @@ 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 | +| 🚩 **Feature flags** | Boolean, string, integer, float, and JSON flags — targeting rules, user segments, percentage rollouts, prerequisites, and a live evaluation stream. Built on the [OpenFeature](https://openfeature.dev/) standard | +| 🏗️ **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 @@ -191,6 +194,70 @@ Requires `api-shield[rate-limit]`. Powered by [limits](https://limits.readthedoc --- +## Feature flags + +api-shield ships a full feature flag system built on the [OpenFeature](https://openfeature.dev/) standard. All five flag types, multi-condition targeting rules, user segments, percentage rollouts, and a live evaluation stream — managed from the dashboard or CLI with no code changes. + +```python +from shield.core.feature_flags.models import ( + FeatureFlag, FlagType, FlagVariation, RolloutVariation, + TargetingRule, RuleClause, Operator, EvaluationContext, +) + +engine.use_openfeature() + +# Define a boolean flag with a 20% rollout and individual targeting +await engine.save_flag( + FeatureFlag( + key="new-checkout", + name="New Checkout Flow", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=[ + RolloutVariation(variation="on", weight=20_000), # 20% + RolloutVariation(variation="off", weight=80_000), # 80% + ], + targets={"on": ["beta_tester_1"]}, # individual targeting + rules=[ + TargetingRule( + description="Enterprise users always get the new flow", + clauses=[RuleClause(attribute="plan", operator=Operator.IS, values=["enterprise"])], + variation="on", + ) + ], + ) +) + +# Evaluate in an async route handler +ctx = EvaluationContext(key=user_id, attributes={"plan": user.plan}) +enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx) + +# Evaluate in a sync def handler (thread-safe) +enabled = engine.sync.flag_client.get_boolean_value("new-checkout", False, {"targeting_key": user_id}) +``` + +Manage flags and segments from the CLI: + +```bash +shield flags list +shield flags eval new-checkout --user user_123 +shield flags disable new-checkout # kill-switch +shield flags enable new-checkout +shield flags stream # live evaluation events + +shield segments create beta_users --name "Beta Users" +shield segments include beta_users --context-key user_123,user_456 +shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise +``` + +Requires `api-shield[flags]`. + +--- + ## Framework support api-shield is built on the **ASGI** standard. The core (`shield.core`) is completely framework-agnostic and has zero framework imports. Any ASGI framework can be supported — either via a Starlette `BaseHTTPMiddleware` (for Starlette-based frameworks) or a raw ASGI callable for frameworks like Quart and Django that implement the ASGI spec independently. @@ -222,14 +289,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 @@ -241,10 +330,13 @@ Full documentation at **[attakay78.github.io/api-shield](https://attakay78.githu | [Tutorial](https://attakay78.github.io/api-shield/tutorial/installation/) | Get started in 5 minutes | | [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 | +| [Feature flags](https://attakay78.github.io/api-shield/tutorial/feature-flags/) | Targeting rules, segments, rollouts, live events | | [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..dd24cff 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), --- +## [0.8.0] + +### 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 + +- **Feature flags** (`api-shield[flags]`): a full feature flag system built on the [OpenFeature](https://openfeature.dev/) standard, supporting boolean, string, integer, float, and JSON flag types with multi-condition targeting rules, reusable user segments (explicit included/excluded lists plus attribute-based rules), percentage rollouts, prerequisite flags, individual user targeting, and a live SSE evaluation stream. Flags and segments are manageable from the admin dashboard (`/shield/flags`, `/shield/segments`) and the CLI (`shield flags *`, `shield segments *`) — including a new `shield segments add-rule` command and an "Add Rule" panel in the Edit Segment modal that lets operators add attribute-based targeting rules without touching code or the REST API directly. + +- **`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/contributing.md b/docs/contributing.md index 5cc3379..1ce8067 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -164,7 +164,7 @@ These constraints are enforced at review time. PRs that violate them will be ask Every request path must flow through `engine.check()`. Never duplicate the check logic in middleware, a dependency, or a decorator. 5. **Backends implement the full `ShieldBackend` ABC.** - No partial implementations. If a method is not supported (e.g. `subscribe()` on `FileBackend`), it raises `NotImplementedError` and callers handle the fallback. + No partial implementations. If a method is not supported (e.g. `subscribe()` on `FileBackend`), it raises `NotImplementedError`. `ShieldEngine.start()` catches this internally and skips the listener — the engine handles the fallback, not the caller. 6. **Fail-open on backend errors.** If `backend.get_state()` raises, `engine.check()` logs the error and lets the request through. Shield must never take down an API because its own storage is temporarily unavailable. @@ -191,11 +191,14 @@ shield/ ├── core/ # Zero framework dependencies — engine, models, backends │ ├── engine.py # ShieldEngine — all business logic lives here │ ├── models.py # RouteState, AuditEntry, RateLimitPolicy, … -│ ├── backends/ # MemoryBackend, FileBackend, RedisBackend +│ ├── backends/ # MemoryBackend, FileBackend, RedisBackend, ShieldServerBackend │ ├── rate_limit/ # Rate limiting subsystem │ └── scheduler.py # asyncio-based maintenance window scheduler ├── fastapi/ # FastAPI adapter — middleware, decorators, router, OpenAPI ├── admin/ # Unified admin ASGI app (dashboard UI + REST API + auth) +├── server/ # ShieldServer — standalone control plane for multi-service deployments +├── sdk/ # ShieldSDK — service-side client that connects to a Shield Server via SSE +├── adapters/ # Framework adapter helpers (ASGI base, future adapter scaffolding) ├── dashboard/ # HTMX/Jinja2 templates and static assets │ ├── templates/ # Edit these, then run `npm run build:css` │ └── static/ # shield.min.css lives here — commit after rebuilding 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..cfe676d --- /dev/null +++ b/docs/guides/shield-server.md @@ -0,0 +1,476 @@ +# 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 +``` + +--- + +## Global maintenance in multi-service environments + +`shield global enable` and `shield global disable` operate on the Shield Server and affect **every route across every connected service** simultaneously. Use this for fleet-wide outages or deployments where all services must be taken offline at once. + +```bash +# Block all routes on all services +shield global enable --reason "Deploying v3" --exempt /health --exempt /ready + +# Restore all routes +shield global disable +``` + +--- + +## Per-service maintenance + +Per-service maintenance puts **all routes of one service** into maintenance mode without affecting other services. SDK clients with a matching `app_id` receive the sentinel over SSE and treat all their routes as in maintenance — no individual route changes needed. + +### From the engine (programmatic) + +```python +# Put payments-service into maintenance +await engine.enable_service_maintenance( + "payments-service", + reason="DB migration", + exempt_paths=["/health"], +) + +# Restore +await engine.disable_service_maintenance("payments-service") + +# Inspect +cfg = await engine.get_service_maintenance("payments-service") +print(cfg.enabled, cfg.reason) +``` + +### From the REST API + +```http +POST /api/services/payments-service/maintenance/enable +Authorization: Bearer +Content-Type: application/json + +{"reason": "DB migration", "exempt_paths": ["/health"]} +``` + +```http +POST /api/services/payments-service/maintenance/disable +``` + +### From the CLI + +`shield sm` and `shield service-maintenance` are aliases for the same command group: + +```bash +# Enable — all routes of payments-service return 503 +shield sm enable payments-service --reason "DB migration" +shield sm enable payments-service --reason "Upgrade" --exempt /health + +# Check current state +shield sm status payments-service + +# Restore +shield sm disable payments-service +``` + +### From the dashboard + +Open the Routes page and select the service using the service filter. A **Service Maintenance** card appears with Enable and Disable controls. + +!!! tip "Use `@force_active` on health checks" + Health and readiness endpoints should always be decorated with `@force_active` so they are never affected by global or per-service maintenance mode. + +### 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 | + +--- + +## Per-service rate limits + +A per-service rate limit applies a single policy to **all routes of one service** without configuring each route individually. It is checked after the all-services global rate limit and before any per-route limit: + +``` +global maintenance -> service maintenance -> global rate limit -> service rate limit -> per-route rate limit +``` + +Configure it with the `shield srl` CLI (also available as `shield service-rate-limit`): + +```bash +# Cap all payments-service routes at 1000 per minute per IP +shield srl set payments-service 1000/minute + +# With options +shield srl set payments-service 500/minute --algorithm sliding_window --key ip +shield srl set payments-service 2000/hour --burst 50 --exempt /health --exempt GET:/metrics + +# Inspect, pause, reset counters, remove +shield srl get payments-service +shield srl disable payments-service +shield srl enable payments-service +shield srl reset payments-service +shield srl delete payments-service +``` + +From the dashboard: open the **Rate Limits** tab and select a service using the service filter. A **Service Rate Limit** card appears above the policies table with controls to configure, pause, reset, and remove the policy. + +The service rate limit uses the same `GlobalRateLimitPolicy` model as the all-services global rate limit. It is stored in the backend under a sentinel key and survives Shield Server restarts on `FileBackend` or `RedisBackend`. + +!!! note "Independent layers" + The all-services global rate limit (`shield grl`) and the per-service rate limit (`shield srl`) are independent. A request must pass both before reaching per-route checking. You can configure one, both, or neither. + +--- + +## 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/index.md b/docs/index.md index ddea47e..5abe2fb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,9 +13,9 @@ !!! warning "Early Access: your feedback shapes the roadmap" `api-shield` is fully functional and ready to use. We are actively building on a solid foundation and would love to hear from you. If you have feedback, feature ideas, or suggestions, **[open an issue on GitHub](https://github.com/Attakay78/api-shield/issues)**. Every voice helps make the library better for everyone. -**Route(API) lifecycle management for ASGI Python web frameworks: maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.** +**Feature flags and runtime control for Python APIs — rollouts, rate limits, manage maintenance windows across single ASGI services or a multi-service fleet without redeploying.** -Most "route lifecycle management" tools are blunt instruments: shut everything down or nothing at all. `api-shield` treats each route as a first-class entity with its own lifecycle. State changes take effect immediately through middleware, with no redeployment and no server restart. +Most "runtime control management" tools are blunt instruments: shut everything down or nothing at all. `api-shield` treats each route as a first-class entity with its own lifecycle. State changes take effect immediately, with no redeployment and no server restart. --- @@ -104,6 +104,7 @@ These features are framework-agnostic and available to every adapter. | ⏰ **Scheduled windows** | `asyncio`-native scheduler that activates and deactivates maintenance windows automatically | | 🔔 **Webhooks** | Fire HTTP POST on every state change, with a built-in Slack formatter and support for custom formatters | | 🚦 **Rate limiting** | Per-IP, per-user, per-API-key, or global counters with tiered limits, burst allowance, and runtime policy mutation | +| 🚩 **Feature flags** | Boolean, string, integer, float, and JSON flags with targeting rules, user segments, percentage rollouts, prerequisites, and a live evaluation stream — built on the OpenFeature standard | ### Framework adapters @@ -162,7 +163,9 @@ api-shield is an **ASGI-native** library. The core (`shield.core`) is framework- - [**Tutorial: Installation**](tutorial/installation.md): get up and running in seconds - [**Tutorial: First Decorator**](tutorial/first-decorator.md): put your first route in maintenance mode - [**Tutorial: Rate Limiting**](tutorial/rate-limiting.md): per-IP, per-user, tiered limits, and more +- [**Tutorial: Feature Flags**](tutorial/feature-flags.md): targeting rules, segments, rollouts, and live events - [**Reference: Decorators**](reference/decorators.md): full decorator API - [**Reference: Rate Limiting**](reference/rate-limiting.md): `@rate_limit` parameters, models, and CLI commands - [**Reference: ShieldEngine**](reference/engine.md): programmatic control +- [**Reference: Feature Flags**](reference/feature-flags.md): full flag/segment API, models, and CLI commands - [**Reference: CLI**](reference/cli.md): all CLI commands diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6712a2f..f7110d3 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. | --- @@ -223,6 +282,75 @@ shield global exempt-remove /monitoring/ping --- +## `shield sm` / `shield service-maintenance` + +`shield sm` and `shield service-maintenance` are aliases for the same command group. Puts all routes of one service into maintenance mode without affecting other services. The affected SDK client's `app_id` must match the service name. + +```bash +shield sm enable payments-service --reason "DB migration" +shield service-maintenance enable payments-service # identical +``` + +### `shield sm status` + +Show the current maintenance configuration for a service. + +```bash +shield sm status +``` + +```bash +shield sm status payments-service +``` + +**Example output:** + +``` + Service maintenance (payments-service): ON + Reason : DB migration + Include @force_active: no + Exempt paths : + • /health +``` + +--- + +### `shield sm enable` + +Block all routes of a service immediately. Routes return `503` until `shield sm disable` is called. + +```bash +shield sm enable +``` + +```bash +shield sm enable payments-service --reason "DB migration" +shield sm enable payments-service --reason "Upgrade" --exempt /health --exempt GET:/ready +shield sm enable orders-service --include-force-active +``` + +| Option | Description | +|---|---| +| `--reason TEXT` | Shown in every 503 response while maintenance is active | +| `--exempt PATH` | Exempt a path from the block (repeatable). Use bare `/health` or `GET:/health`. | +| `--include-force-active` | Also block `@force_active` routes. Use with care. | + +--- + +### `shield sm disable` + +Restore all routes of a service to their individual states. + +```bash +shield sm disable +``` + +```bash +shield sm disable payments-service +``` + +--- + ## Rate limit commands `shield rl` and `shield rate-limits` are aliases for the same command group — use whichever you prefer. Requires `api-shield[rate-limit]` on the server. @@ -391,11 +519,105 @@ shield grl disable --- +## `shield srl` / `shield service-rate-limit` + +`shield srl` and `shield service-rate-limit` are aliases for the same command group. Manages the rate limit policy for a single service — applies to all routes of that service. Requires `api-shield[rate-limit]` on the server. + +```bash +shield srl get payments-service +shield service-rate-limit get payments-service # identical +``` + +### `shield srl get` + +Show the current rate limit policy for a service, including limit, algorithm, key strategy, burst, exempt routes, and enabled state. + +```bash +shield srl get +``` + +```bash +shield srl get payments-service +``` + +--- + +### `shield srl set` + +Configure the rate limit for a service. Creates a new policy or replaces the existing one. + +```bash +shield srl set +``` + +```bash +shield srl set payments-service 1000/minute +shield srl set payments-service 500/minute --algorithm sliding_window --key ip +shield srl set payments-service 2000/hour --burst 50 --exempt /health --exempt GET:/metrics +``` + +| Option | Description | +|---|---| +| `--algorithm TEXT` | Counting algorithm: `fixed_window`, `sliding_window`, `moving_window`, `token_bucket` | +| `--key TEXT` | Key strategy: `ip`, `user`, `api_key`, `global` | +| `--burst INT` | Extra requests above the base limit | +| `--exempt TEXT` | Exempt route (repeatable). Bare path (`/health`) or method-prefixed (`GET:/metrics`) | + +--- + +### `shield srl delete` + +Remove the service rate limit policy entirely. + +```bash +shield srl delete +``` + +```bash +shield srl delete payments-service +``` + +--- + +### `shield srl reset` + +Clear all counters for the service. The policy is kept; clients get their full quota back on the next request. + +```bash +shield srl reset +``` + +```bash +shield srl reset payments-service +``` + +--- + +### `shield srl enable` + +Resume a paused service rate limit policy. + +```bash +shield srl enable +``` + +--- + +### `shield srl disable` + +Pause the service rate limit without removing it. Per-route policies continue to enforce normally. + +```bash +shield srl disable +``` + +--- + ## Audit log ### `shield log` -Display the audit log, newest entries first. The `Status` column shows `old > new` for route state changes and a coloured action label for rate limit policy changes (including global RL actions such as `global set`, `global reset`, `global enabled`, `global disabled`). +Display the audit log, newest entries first. The `Status` column shows `old > new` for route state changes and a coloured action label for rate limit policy changes (including global RL actions such as `global set`, `global reset`, `global enabled`, `global disabled`, and service RL actions such as `svc set`, `svc reset`, `svc enabled`, `svc disabled`). The `Path` column shows human-readable labels for sentinel-keyed entries: `[Global Maintenance]`, `[Global Rate Limit]`, `[{service} Maintenance]`, and `[{service} Rate Limit]`. ```bash shield log # page 1, 20 rows diff --git a/docs/reference/engine.md b/docs/reference/engine.md index 4dca111..67e2803 100644 --- a/docs/reference/engine.md +++ b/docs/reference/engine.md @@ -440,6 +440,82 @@ Update the exempt path list while global maintenance is active, without toggling --- +## Per-service maintenance + +Per-service maintenance puts **all routes of one service** into maintenance mode at once, without touching other services or requiring individual route changes. It uses the same `GlobalMaintenanceConfig` model and exempt-paths mechanism as all-services global maintenance, but is scoped to a single `app_id`. + +Available via the engine, the `shield sm` CLI command group, the REST API (`POST /api/services/{service}/maintenance/enable|disable`), and the dashboard Routes page when a service filter is active. + +Audit log actions: `service_maintenance_on` (enabled), `service_maintenance_off` (disabled). The `Path` column displays as `[{service} Maintenance]`. + +### `enable_service_maintenance` + +```python +async def enable_service_maintenance( + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + actor: str = "system", + platform: str = "system", +) -> GlobalMaintenanceConfig +``` + +Block all routes for *service* immediately. SDK clients with a matching `app_id` receive the sentinel via SSE and treat every request as if their own global maintenance were enabled. Exempt paths respond normally. + +```python title="example" +await engine.enable_service_maintenance( + "payments-service", + reason="DB migration", + exempt_paths=["/health"], + actor="alice", +) +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `service` | `str` | required | The service `app_id` to put into maintenance | +| `reason` | `str` | `""` | Shown in every 503 response while maintenance is active | +| `exempt_paths` | `list[str] \| None` | `None` | Paths that bypass the service maintenance check. Bare path or `METHOD:/path`. | +| `include_force_active` | `bool` | `False` | When `True`, even `@force_active` routes on this service are blocked | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | + +--- + +### `disable_service_maintenance` + +```python +async def disable_service_maintenance( + service: str, + actor: str = "system", + platform: str = "system", +) -> GlobalMaintenanceConfig +``` + +Restore all routes of *service* to their individual states. + +```python title="example" +await engine.disable_service_maintenance("payments-service", actor="alice") +``` + +--- + +### `get_service_maintenance` + +```python +async def get_service_maintenance(service: str) -> GlobalMaintenanceConfig +``` + +Return the current per-service maintenance configuration. Returns a `GlobalMaintenanceConfig` with `enabled=False` when no sentinel is stored for this service. + +```python title="example" +cfg = await engine.get_service_maintenance("payments-service") +if cfg.enabled: + print(f"payments-service in maintenance: {cfg.reason}") +``` + +--- + ## Audit log ### `get_audit_log` diff --git a/docs/reference/feature-flags.md b/docs/reference/feature-flags.md new file mode 100644 index 0000000..9a00acc --- /dev/null +++ b/docs/reference/feature-flags.md @@ -0,0 +1,481 @@ +# Feature Flags Reference + +API reference for the feature flag system. + +!!! note "Optional dependency" + ```bash + uv add "api-shield[flags]" + ``` + +--- + +## Engine methods + +### `engine.use_openfeature()` + +Activate the feature flag subsystem. Call once before any flag evaluation or flag/segment CRUD. + +```python +engine = make_engine() +engine.use_openfeature() +``` + +--- + +### `engine.flag_client` + +OpenFeature-compatible async flag client. Available after `use_openfeature()`. + +```python +value = await engine.flag_client.get_boolean_value(flag_key, default, context) +value = await engine.flag_client.get_string_value(flag_key, default, context) +value = await engine.flag_client.get_integer_value(flag_key, default, context) +value = await engine.flag_client.get_float_value(flag_key, default, context) +value = await engine.flag_client.get_object_value(flag_key, default, context) +``` + +| Parameter | Type | Description | +|---|---|---| +| `flag_key` | `str` | The flag's unique key | +| `default` | `Any` | Returned when the flag is not found or an error occurs | +| `context` | `EvaluationContext` | Per-request context for targeting | + +--- + +### `engine.sync.flag_client` + +Thread-safe synchronous version for `def` (non-async) route handlers. + +```python +enabled = engine.sync.flag_client.get_boolean_value("my-flag", False, ctx) +``` + +Accepts `EvaluationContext` objects or plain dicts (`{"targeting_key": user_id, ...}`). + +--- + +### `await engine.save_flag(flag, *, actor, platform, action, audit)` + +Create or replace a flag. Writes an audit log entry unless `audit=False`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `flag` | `FeatureFlag` | required | Flag to persist | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log (`"dashboard"`, `"cli"`, etc.) | +| `action` | `str \| None` | `None` | Override the audit action string. Defaults to `flag_created` or `flag_updated` based on whether the flag already existed | +| `audit` | `bool` | `True` | Set to `False` to skip writing an audit log entry. Use this for startup seeds and programmatic initialization | + +```python +# Normal save — audited +await engine.save_flag(FeatureFlag(key="my-flag", ...)) + +# Startup seed — no audit entry +await engine.save_flag(FeatureFlag(key="my-flag", ...), audit=False) +``` + +--- + +### `await engine.get_flag(key)` + +Return the `FeatureFlag` for `key`, or `None` if not found. + +--- + +### `await engine.list_flags()` + +Return all flags as a list. + +--- + +### `await engine.delete_flag(key, *, actor, platform, audit)` + +Delete a flag. Writes an audit log entry unless `audit=False`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `key` | `str` | required | Key of the flag to delete | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log | +| `audit` | `bool` | `True` | Set to `False` to skip the audit entry | + +--- + +### `await engine.save_segment(segment, *, actor, platform, audit)` + +Create or replace a segment. Writes an audit log entry unless `audit=False`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `segment` | `Segment` | required | Segment to persist | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log | +| `audit` | `bool` | `True` | Set to `False` to skip writing an audit log entry. Use this for startup seeds and programmatic initialization | + +```python +# Startup seed — no audit entry +await engine.save_segment(Segment(key="beta-users", ...), audit=False) +``` + +--- + +### `await engine.get_segment(key)` + +Return the `Segment` for `key`, or `None`. + +--- + +### `await engine.list_segments()` + +Return all segments as a list. + +--- + +### `await engine.delete_segment(key, *, actor, platform, audit)` + +Delete a segment. Writes an audit log entry unless `audit=False`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `key` | `str` | required | Key of the segment to delete | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log | +| `audit` | `bool` | `True` | Set to `False` to skip the audit entry | + +--- + +## Models + +### `FeatureFlag` + +Definition of a feature flag. + +```python +class FeatureFlag(BaseModel): + key: str + name: str + description: str = "" + type: FlagType + tags: list[str] = [] + + variations: list[FlagVariation] + off_variation: str + fallthrough: str | list[RolloutVariation] + + enabled: bool = True + prerequisites: list[Prerequisite] = [] + targets: dict[str, list[str]] = {} + rules: list[TargetingRule] = [] + scheduled_changes: list[ScheduledChange] = [] + + status: FlagStatus = FlagStatus.ACTIVE + temporary: bool = True + maintainer: str | None = None + created_at: datetime + updated_at: datetime + created_by: str = "system" +``` + +| Field | Description | +|---|---| +| `key` | Unique identifier used in code: `get_boolean_value("my-flag", ...)` | +| `name` | Human-readable display name | +| `type` | `FlagType.BOOLEAN`, `STRING`, `INTEGER`, `FLOAT`, or `JSON` | +| `variations` | All possible values; must contain at least two | +| `off_variation` | Variation served when `enabled=False` | +| `fallthrough` | Default when no rule matches: a variation name (`str`) or a percentage rollout (`list[RolloutVariation]`) | +| `enabled` | Kill-switch. `False` means all requests get `off_variation` | +| `prerequisites` | Flags that must pass before this flag's rules run | +| `targets` | Individual targeting: `{"on": ["user_1", "user_2"]}` | +| `rules` | Targeting rules evaluated top-to-bottom; first match wins | + +--- + +### `FlagType` + +```python +class FlagType(StrEnum): + BOOLEAN = "boolean" + STRING = "string" + INTEGER = "integer" + FLOAT = "float" + JSON = "json" +``` + +--- + +### `FlagVariation` + +One possible value a flag can return. + +```python +class FlagVariation(BaseModel): + name: str # e.g. "on", "off", "control", "variant_a" + value: bool | str | int | float | dict | list + description: str = "" +``` + +--- + +### `RolloutVariation` + +One bucket in a percentage rollout (used in `fallthrough` or `TargetingRule.rollout`). + +```python +class RolloutVariation(BaseModel): + variation: str # references FlagVariation.name + weight: int # share of traffic, out of 100_000 total +``` + +Weights in a rollout list must sum to `100_000`. Examples: + +| Percentage | Weight | +|---|---| +| 10% | `10_000` | +| 25% | `25_000` | +| 33.33% | `33_333` | +| 50% | `50_000` | + +--- + +### `TargetingRule` + +A rule that matches clauses and serves a variation. + +```python +class TargetingRule(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = [] + variation: str | None = None # mutually exclusive with rollout + rollout: list[RolloutVariation] | None = None + track_events: bool = False +``` + +Clauses within a rule are AND-ed. Rules are evaluated top-to-bottom; first match wins. + +--- + +### `RuleClause` + +A single condition within a targeting rule. + +```python +class RuleClause(BaseModel): + attribute: str # context attribute to inspect, e.g. "plan", "country", "email" + operator: Operator # comparison to apply + values: list[Any] # one or more values; multiple values use OR logic + negate: bool = False # invert the result +``` + +--- + +### `Operator` + +All supported targeting operators. + +```python +class Operator(StrEnum): + # Equality + IS = "is" + IS_NOT = "is_not" + # String + CONTAINS = "contains" + NOT_CONTAINS = "not_contains" + STARTS_WITH = "starts_with" + ENDS_WITH = "ends_with" + MATCHES = "matches" # Python regex + NOT_MATCHES = "not_matches" + # Numeric + GT = "gt" + GTE = "gte" + LT = "lt" + LTE = "lte" + # Date (ISO-8601 lexicographic) + BEFORE = "before" + AFTER = "after" + # Collection + IN = "in" + NOT_IN = "not_in" + # Segment + IN_SEGMENT = "in_segment" + NOT_IN_SEGMENT = "not_in_segment" + # Semantic version (requires `packaging`) + SEMVER_EQ = "semver_eq" + SEMVER_LT = "semver_lt" + SEMVER_GT = "semver_gt" +``` + +--- + +### `Prerequisite` + +A flag that must evaluate to a specific variation before the dependent flag runs. + +```python +class Prerequisite(BaseModel): + flag_key: str # key of the prerequisite flag + variation: str # variation the prerequisite must return +``` + +If the prerequisite returns any other variation, the dependent flag serves `off_variation` with reason `PREREQUISITE_FAIL`. + +--- + +### `Segment` + +A reusable group of users for flag targeting. + +```python +class Segment(BaseModel): + key: str + name: str + description: str = "" + included: list[str] = [] # context keys always in the segment + excluded: list[str] = [] # context keys always excluded (overrides rules + included) + rules: list[SegmentRule] = [] + tags: list[str] = [] + created_at: datetime + updated_at: datetime +``` + +**Evaluation order for context key `k`:** + +1. `k` in `excluded` → not in segment +2. `k` in `included` → in segment +3. Any `SegmentRule` matches → in segment +4. Otherwise → not in segment + +--- + +### `SegmentRule` + +An attribute-based rule inside a segment. Multiple segment rules are OR-ed: if any rule matches, the user is in the segment. + +```python +class SegmentRule(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = [] # all must match (AND logic) +``` + +--- + +### `EvaluationContext` + +Per-request context for flag targeting and rollout bucketing. + +```python +class EvaluationContext(BaseModel): + key: str # required — user/session/org ID + kind: str = "user" # context kind + email: str | None = None + ip: str | None = None + country: str | None = None + app_version: str | None = None + attributes: dict[str, Any] = {} # any additional attributes +``` + +Named fields (`email`, `ip`, `country`, `app_version`) are accessible in rule clauses by the same names. Items in `attributes` are merged in and accessible by key. + +--- + +### `ResolutionDetails` + +Full result of a flag evaluation, surfaced in hooks. + +```python +class ResolutionDetails(BaseModel): + value: Any + variation: str | None = None + reason: EvaluationReason + rule_id: str | None = None # set when reason == RULE_MATCH + prerequisite_key: str | None = None # set when reason == PREREQUISITE_FAIL + error_message: str | None = None # set when reason == ERROR +``` + +--- + +### `EvaluationReason` + +Why a specific value was returned. + +| Value | Description | +|---|---| +| `OFF` | Flag is globally disabled. `off_variation` was served. | +| `TARGET_MATCH` | Context key was in the individual targets list. | +| `RULE_MATCH` | A targeting rule matched. `rule_id` is set. | +| `FALLTHROUGH` | No targeting rule matched. Default rule was served. | +| `PREREQUISITE_FAIL` | A prerequisite flag did not return the required variation. | +| `ERROR` | Provider or evaluation error. Default value was returned. | +| `DEFAULT` | Flag not found. SDK default was returned. | + +--- + +--- + +## REST API + +When `ShieldAdmin` is mounted with `engine.use_openfeature()`, these endpoints are registered under the admin path (e.g. `/shield/api/`): + +### Flags + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/flags` | List all flags | +| `POST` | `/api/flags` | Create a flag (full `FeatureFlag` body) | +| `GET` | `/api/flags/{key}` | Get a single flag | +| `PUT` | `/api/flags/{key}` | Replace a flag (full update) | +| `PATCH` | `/api/flags/{key}` | Partial update | +| `DELETE` | `/api/flags/{key}` | Delete a flag | +| `POST` | `/api/flags/{key}/enable` | Enable (kill-switch off) | +| `POST` | `/api/flags/{key}/disable` | Disable (kill-switch on) | +| `POST` | `/api/flags/{key}/evaluate` | Evaluate for a given context | + +### Segments + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/segments` | List all segments | +| `POST` | `/api/segments` | Create a segment | +| `GET` | `/api/segments/{key}` | Get a single segment | +| `PUT` | `/api/segments/{key}` | Replace a segment | +| `DELETE` | `/api/segments/{key}` | Delete a segment | + +--- + +## Evaluation algorithm + +The evaluator (`FlagEvaluator`) is pure Python with no I/O, unit-testable in isolation. + +```python +from shield.core.feature_flags.evaluator import FlagEvaluator + +evaluator = FlagEvaluator(segments={"beta": beta_segment}) +result = evaluator.evaluate(flag, ctx, all_flags) +print(result.value, result.reason) +``` + +**Rollout bucketing** uses SHA-1 of `"{flag_key}:{ctx.kind}:{ctx.key}"` modulo `100_000`. The same context always lands in the same bucket; bucketing is stable across restarts and deploys. + +**Prerequisite recursion** is limited to depth 10. Circular dependencies are rejected at write time by `engine.save_flag()`. + +--- + +## Dashboard routes + +| URL | Page | +|---|---| +| `/shield/flags` | Flag list with search and status filters | +| `/shield/flags/{key}` | Flag detail (4 tabs: Overview, Targeting, Variations, Settings) | +| `/shield/segments` | Segment list | + +--- + +## Example + +Example: [`examples/fastapi/feature_flags.py`](https://github.com/Attakay78/api-shield/blob/main/examples/fastapi/feature_flags.py) + +```bash +uv run uvicorn examples.fastapi.feature_flags:app --reload +``` diff --git a/docs/reference/rate-limiting.md b/docs/reference/rate-limiting.md index e9df8f4..35faa94 100644 --- a/docs/reference/rate-limiting.md +++ b/docs/reference/rate-limiting.md @@ -460,6 +460,109 @@ The **Rate Limits** page includes a Global Rate Limit card above the policies ta --- +## Per-service rate limit + +A per-service rate limit applies a `GlobalRateLimitPolicy` to **all routes of one service**. It sits between the all-services global rate limit and per-route limits in the enforcement chain: + +``` +global maintenance -> service maintenance -> global rate limit -> service rate limit -> per-route rate limit +``` + +Uses the same `GlobalRateLimitPolicy` model as the all-services global rate limit. The policy is persisted in the backend via a sentinel key so it survives restarts. + +### Engine methods + +#### `set_service_rate_limit` + +```python +async def set_service_rate_limit( + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "", +) -> GlobalRateLimitPolicy +``` + +Create or replace the rate limit policy for a service. Persists to the backend. Logged as `svc_rl_set` (new) or `svc_rl_updated` (replacement). + +```python +await engine.set_service_rate_limit( + "payments-service", + "1000/minute", + key_strategy="ip", + exempt_routes=["/health", "GET:/metrics"], + actor="alice", +) +``` + +--- + +#### `get_service_rate_limit` + +```python +async def get_service_rate_limit(service: str) -> GlobalRateLimitPolicy | None +``` + +Return the current policy for a service, or `None` if not configured. + +--- + +#### `delete_service_rate_limit` + +```python +async def delete_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Remove the service rate limit policy entirely. Logged as `svc_rl_deleted`. + +--- + +#### `reset_service_rate_limit` + +```python +async def reset_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Clear all counters for the service so the limit starts fresh. The policy is not removed. Logged as `svc_rl_reset`. + +--- + +#### `enable_service_rate_limit` + +```python +async def enable_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Resume a paused service rate limit policy. No-op if already enabled or not configured. Logged as `svc_rl_enabled`. + +--- + +#### `disable_service_rate_limit` + +```python +async def disable_service_rate_limit(service: str, *, actor: str = "system") -> None +``` + +Pause the service rate limit without removing it. Per-route policies are unaffected. Logged as `svc_rl_disabled`. + +--- + +### Dashboard + +When a service filter is active on the **Rate Limits** page (`/shield/rate-limits?service=`), a **Service Rate Limit** card appears between the global RL card and the policies table. + +- **Not configured** — compact bar with a "Set Service Limit" button. +- **Active** — info card showing limit, algorithm, key strategy, burst, and exempt routes. Action buttons: Pause, Edit, Reset, Remove. +- **Paused** — same card with a "Paused" badge and a Resume button. + +--- + ## CLI commands `shield rl` and `shield rate-limits` are aliases for the same command group — use whichever you prefer. @@ -543,10 +646,6 @@ shield rl hits --limit 50 # show more --- -Now add the global rate limit CLI commands section after `shield rl hits`: - ---- - ### `shield grl` / `shield global-rate-limit` `shield grl` and `shield global-rate-limit` are aliases for the global rate limit command group. @@ -629,6 +728,100 @@ shield grl disable --- +## `shield srl` / `shield service-rate-limit` + +`shield srl` and `shield service-rate-limit` are aliases for the per-service rate limit command group. Requires `api-shield[rate-limit]` on the server. + +```bash +shield srl get payments-service +shield service-rate-limit get payments-service # identical +``` + +### `shield srl get` + +Show the current rate limit policy for a service. + +```bash +shield srl get +``` + +```bash +shield srl get payments-service +``` + +--- + +### `shield srl set` + +Configure the rate limit for a service. Creates a new policy or replaces the existing one. + +```bash +shield srl set +``` + +```bash +shield srl set payments-service 1000/minute +shield srl set payments-service 500/minute --algorithm sliding_window --key ip +shield srl set payments-service 2000/hour --burst 50 --exempt /health --exempt GET:/metrics +``` + +| Option | Description | +|---|---| +| `--algorithm TEXT` | Counting algorithm: `fixed_window`, `sliding_window`, `moving_window`, `token_bucket` | +| `--key TEXT` | Key strategy: `ip`, `user`, `api_key`, `global` | +| `--burst INT` | Extra requests above the base limit | +| `--exempt TEXT` | Exempt route (repeatable). Bare path (`/health`) or method-prefixed (`GET:/metrics`) | + +--- + +### `shield srl delete` + +Remove the service rate limit policy entirely. + +```bash +shield srl delete +``` + +```bash +shield srl delete payments-service +``` + +--- + +### `shield srl reset` + +Clear all counters for the service. The policy is kept; clients get their full quota back on the next request. + +```bash +shield srl reset +``` + +```bash +shield srl reset payments-service +``` + +--- + +### `shield srl enable` + +Resume a paused service rate limit policy. + +```bash +shield srl enable +``` + +--- + +### `shield srl disable` + +Pause the service rate limit without removing it. Per-route policies continue to enforce normally. + +```bash +shield srl disable +``` + +--- + ## Audit log integration Rate limit policy changes are recorded in the same audit log as route state changes. The `action` field uses the following values: @@ -642,7 +835,7 @@ Rate limit policy changes are recorded in the same audit log as route state chan | `rl_reset` | reset | Per-route counters cleared | | `rl_policy_deleted` | delete | Per-route policy removed | -**Global:** +**Global (all services):** | Action | Badge | When | |---|---|---| @@ -653,6 +846,19 @@ Rate limit policy changes are recorded in the same audit log as route state chan | `global_rl_enabled` | global enabled | Policy resumed after pause | | `global_rl_disabled` | global disabled | Policy paused | +**Per-service:** + +| Action | Badge | When | +|---|---|---| +| `svc_rl_set` | svc set | Service policy created | +| `svc_rl_updated` | svc update | Service policy replaced | +| `svc_rl_reset` | svc reset | Service counters cleared | +| `svc_rl_deleted` | svc delete | Service policy removed | +| `svc_rl_enabled` | svc enabled | Service policy resumed after pause | +| `svc_rl_disabled` | svc disabled | Service policy paused | + +The `Path` column for service rate limit entries displays as `[{service} Rate Limit]` (e.g. `[payments-service Rate Limit]`). + View in the dashboard at `/shield/audit` or via `shield log`. --- 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/feature-flags.md b/docs/tutorial/feature-flags.md new file mode 100644 index 0000000..ed1e812 --- /dev/null +++ b/docs/tutorial/feature-flags.md @@ -0,0 +1,467 @@ +# Feature Flags + +Feature flags (also called feature toggles) let you change your application's behavior per user without redeploying. The system is built on the [OpenFeature](https://openfeature.dev/) standard and supports boolean, string, integer, float, and JSON flags, multi-condition targeting rules, user segments, percentage rollouts, and prerequisites. + +!!! note "Optional dependency" + Feature flags require the `flags` extra: + ```bash + uv add "api-shield[flags]" + # or: pip install "api-shield[flags]" + ``` + +--- + +## Overview + +A feature flag has: + +- **Variations**: the possible values it can return (`on`/`off`, `"dark"`/`"light"`, `10`/`50`, etc.) +- **Targeting**: rules that decide which variation a specific user receives +- **Fallthrough**: the default variation when no rule matches (a fixed value or a percentage rollout) +- **Kill-switch**: `enabled=False` skips all rules and returns the `off_variation` immediately + +Evaluation always follows this order: + +``` +1. Flag disabled? → off_variation +2. Prerequisite flags? → off_variation if any prereq fails +3. Individual targets? → fixed variation for specific user keys +4. Targeting rules? → first matching rule wins +5. Fallthrough → fixed variation or percentage bucket +``` + +--- + +## Installation and setup + +```bash +uv add "api-shield[flags]" +``` + +Call `engine.use_openfeature()` once before your first evaluation, then access the flag client through `engine.flag_client`: + +```python +from shield.core.config import make_engine + +engine = make_engine() +engine.use_openfeature() # activates the feature flag subsystem +``` + +The flag client is a standard OpenFeature client — any OpenFeature-aware code works with it directly. + +--- + +## Your first flag + +```python +from shield.core.feature_flags.models import ( + FeatureFlag, FlagType, FlagVariation, RolloutVariation, + EvaluationContext, +) + +# 1. Define and save the flag +await engine.save_flag( + FeatureFlag( + key="new-checkout", + name="New Checkout Flow", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=[ # 20% of users get "on" + RolloutVariation(variation="on", weight=20_000), + RolloutVariation(variation="off", weight=80_000), + ], + ) +) + +# 2. Evaluate it in a route handler +ctx = EvaluationContext(key=user_id) +enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx) +``` + +Rollout weights are integers out of `100_000`. The above gives exactly 20% to `"on"` and 80% to `"off"`. Bucketing is deterministic: the same `user_id` always lands in the same bucket. + +!!! tip "Seeding flags at startup" + When pre-loading flags in a lifespan or startup function, pass `audit=False` so these programmatic writes do not appear in the audit log: + ```python + @asynccontextmanager + async def lifespan(_): + await engine.save_flag(FeatureFlag(key="new-checkout", ...), audit=False) + yield + ``` + Flags created or updated through the dashboard, REST API, or CLI always audit regardless of this parameter. + +--- + +## Flag types + +| Type | Method | Python type | +|---|---|---| +| `FlagType.BOOLEAN` | `get_boolean_value` | `bool` | +| `FlagType.STRING` | `get_string_value` | `str` | +| `FlagType.INTEGER` | `get_integer_value` | `int` | +| `FlagType.FLOAT` | `get_float_value` | `float` | +| `FlagType.JSON` | `get_object_value` | `dict` / `list` | + +All evaluation methods share the same signature: `(flag_key, default_value, context)`. + +```python +# String flag +theme = await engine.flag_client.get_string_value("ui-theme", "light", ctx) + +# Integer flag +page_size = await engine.flag_client.get_integer_value("page-size", 10, ctx) + +# Float flag +discount = await engine.flag_client.get_float_value("discount-rate", 0.0, ctx) + +# JSON flag — returns a dict +config = await engine.flag_client.get_object_value("feature-config", {}, ctx) +``` + +--- + +## Evaluation context + +`EvaluationContext` identifies who is making the request. The `key` field is required; use a stable user or session identifier. Everything else is optional: + +```python +ctx = EvaluationContext( + key=user.id, # required — used for individual targeting + rollout bucketing + kind="user", # optional — defaults to "user" + email=user.email, # accessible in rules as the "email" attribute + ip=request.client.host, + country=user.country, + app_version="2.3.1", + attributes={ # any extra attributes your rules need + "plan": user.plan, + "role": user.role, + }, +) +``` + +Named fields (`email`, `ip`, `country`, `app_version`) are accessible in targeting rules by the same names. Custom attributes go in `attributes`. + +--- + +## Targeting rules + +Targeting rules serve a specific variation to users who match certain conditions. + +### Attribute-based rule + +```python +from shield.core.feature_flags.models import TargetingRule, RuleClause, Operator + +FeatureFlag( + key="ui-theme", + ... + rules=[ + TargetingRule( + description="Corporate users → dark theme", + clauses=[ + RuleClause( + attribute="email", + operator=Operator.ENDS_WITH, + values=["@acme.com"], + ) + ], + variation="dark", + ) + ], +) +``` + +### Multiple clauses (AND logic) + +All clauses within a rule must match (AND). Multiple values within one clause are OR-ed. + +```python +TargetingRule( + description="GB Pro users → full discount", + clauses=[ + RuleClause(attribute="country", operator=Operator.IS, values=["GB"]), + RuleClause(attribute="plan", operator=Operator.IN, values=["pro", "enterprise"]), + ], + variation="full", +) +``` + +### Negation + +Flip the result of any clause with `negate=True`: + +```python +RuleClause(attribute="plan", operator=Operator.IS, values=["free"], negate=True) +# matches any user NOT on the free plan +``` + +### Available operators + +| Category | Operators | +|---|---| +| Equality | `IS`, `IS_NOT` | +| String | `CONTAINS`, `NOT_CONTAINS`, `STARTS_WITH`, `ENDS_WITH`, `MATCHES`, `NOT_MATCHES` | +| Numeric | `GT`, `GTE`, `LT`, `LTE` | +| Date | `BEFORE`, `AFTER` (ISO-8601 string comparison) | +| Collection | `IN`, `NOT_IN` | +| Segment | `IN_SEGMENT`, `NOT_IN_SEGMENT` | +| Semver | `SEMVER_EQ`, `SEMVER_LT`, `SEMVER_GT` | + +--- + +## Individual targeting + +Override rules for specific users by listing their context keys in `targets`. Individual targets are evaluated after prerequisites but before rules, and always win. + +```python +FeatureFlag( + key="new-checkout", + ... + targets={ + "on": ["beta_tester_1", "beta_tester_2"], # these users always get "on" + "off": ["opted_out_user"], # this user always gets "off" + }, +) +``` + +--- + +## Segments + +A segment is a named, reusable group of users. Define it once and reference it in any flag's targeting rules with `Operator.IN_SEGMENT`. + +### Creating a segment + +```python +from shield.core.feature_flags.models import Segment, SegmentRule, RuleClause, Operator + +# Explicit include list +await engine.save_segment(Segment( + key="beta-users", + name="Beta Users", + included=["user_123", "user_456", "user_789"], +)) + +# Attribute-based rules (any matching rule → user is in the segment) +await engine.save_segment(Segment( + key="enterprise-plan", + name="Enterprise Plan", + rules=[ + SegmentRule(clauses=[ + RuleClause(attribute="plan", operator=Operator.IS, values=["enterprise"]), + ]), + ], +)) + +# Exclude specific users even if they match a rule +await engine.save_segment(Segment( + key="paid-users", + name="Paid Users", + rules=[ + SegmentRule(clauses=[ + RuleClause(attribute="plan", operator=Operator.IN, values=["pro", "enterprise"]), + ]), + ], + excluded=["test_account", "demo_user"], # always excluded, overrides rules +)) +``` + +Pass `audit=False` when seeding segments at startup, same as with flags. + +### Segment evaluation order + +For a given context key `k`: + +1. `k` in `excluded` → **not** in segment +2. `k` in `included` → in segment +3. Any `rules` entry matches → in segment +4. Otherwise → not in segment + +!!! important "Segment key ≠ user key" + The segment **key** (e.g. `"beta-users"`) is the segment's identifier. To make a user with `user_id="alice"` part of this segment, add `"alice"` to `included` — or add a segment rule that matches her attributes. Simply naming the segment `"alice"` does not put her in it. + +### Using a segment in a flag rule + +```python +TargetingRule( + description="Beta users get the new flow", + clauses=[ + RuleClause( + attribute="key", # evaluates ctx.key against the segment + operator=Operator.IN_SEGMENT, + values=["beta-users"], # segment key to reference + ) + ], + variation="on", +) +``` + +### Managing segments from the dashboard + +Open the **Segments** page (`/shield/segments`) and click a segment key or **Edit** to: + +- Add or remove users from the **Included** and **Excluded** lists +- Add **targeting rules** — attribute-based conditions evaluated when a user isn't in the explicit lists + +### Managing segments from the CLI + +```bash +# List all segments +shield segments list + +# Inspect a segment +shield segments get beta-users + +# Create a segment +shield segments create beta_users --name "Beta Users" + +# Add users to the included list +shield segments include beta_users --context-key user_123,user_456 + +# Remove users via the excluded list +shield segments exclude beta_users --context-key opted_out_user + +# Add an attribute-based targeting rule +shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise +shield segments add-rule beta_users --attribute country --operator is --values GB --description "UK users" + +# Remove a rule (use 'shield segments get' to find rule IDs) +shield segments remove-rule beta_users --rule-id + +# Delete a segment +shield segments delete beta_users +``` + +--- + +## Prerequisites + +Prerequisites let a flag depend on another flag. The dependent flag only proceeds to its rules if the prerequisite flag evaluates to a specific variation. + +```python +from shield.core.feature_flags.models import Prerequisite + +FeatureFlag( + key="advanced-dashboard", + ... + prerequisites=[ + Prerequisite(flag_key="auth-v2", variation="enabled"), + # advanced-dashboard only evaluates if auth-v2 → "enabled" + ], +) +``` + +Prerequisites are recursive up to a depth of 10. Circular dependencies are prevented at write time. + +--- + +## Sync evaluation (plain `def` handlers) + +FastAPI runs plain `def` route handlers in a thread pool. Use `engine.sync.flag_client` for thread-safe synchronous evaluation without any event loop bridging: + +```python +@router.get("/dashboard") +def dashboard(request: Request, user_id: str = "anonymous"): + enabled = engine.sync.flag_client.get_boolean_value( + "new-dashboard", False, {"targeting_key": user_id} + ) + return {"new_dashboard": enabled} +``` + +--- + +## Admin dashboard + +### Flags page (`/shield/flags`) + +Lists all flags with key, type, status, variations, and fallthrough. Use the search box and type/status filters to narrow the list. Click a flag key to open the detail page. + +### Flag detail page + +| Tab | Contents | +|---|---| +| **Overview** | Key metrics: evaluation count, rule match rate, fallthrough rate, top variations | +| **Targeting** | Add / remove prerequisite flags; manage individual targets; add / edit / delete targeting rules | +| **Variations** | Add, rename, or remove variations; change the fallthrough and off-variation | +| **Settings** | Edit name, description, tags, maintainer, temporary flag flag, and scheduled changes | + +### Segments page (`/shield/segments`) + +Lists all segments with included/excluded/rules counts. Click a segment to open its detail modal, or use the **Edit** button to manage included, excluded, and targeting rules. + +--- + +## CLI reference + +### `shield flags` + +```bash +shield flags list # all flags +shield flags get new-checkout # flag detail +shield flags create new-checkout boolean # create (interactive prompts follow) +shield flags enable new-checkout # enable (kill-switch off) +shield flags disable new-checkout # disable (kill-switch on) +shield flags delete new-checkout # permanently delete + +shield flags eval new-checkout --user user_123 # evaluate for a user + +shield flags targeting new-checkout # show targeting rules +shield flags add-rule new-checkout \ + --variation on \ + --segment beta-users # add segment-based rule +shield flags add-rule new-checkout \ + --variation on \ + --attribute plan --operator in --values pro,enterprise +shield flags remove-rule new-checkout --rule-id + +shield flags add-prereq new-checkout --flag auth-v2 --variation enabled +shield flags remove-prereq new-checkout --flag auth-v2 + +shield flags target new-checkout --variation on --context-key user_123 +shield flags untarget new-checkout --context-key user_123 + +shield flags variations new-checkout # list variations +shield flags edit new-checkout # open interactive editor +``` + +### `shield segments` + +```bash +shield segments list +shield segments get beta-users +shield segments create beta_users --name "Beta Users" +shield segments include beta_users --context-key user_123,user_456 +shield segments exclude beta_users --context-key opted_out +shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise +shield segments remove-rule beta_users --rule-id +shield segments delete beta_users +``` + +--- + +## Full example + +Full example at [`examples/fastapi/feature_flags.py`](https://github.com/Attakay78/api-shield/blob/main/examples/fastapi/feature_flags.py), covering all five flag types, individual targeting, attribute-based rules, percentage rollouts, and sync and async evaluation. + +Run it with: + +```bash +uv run uvicorn examples.fastapi.feature_flags:app --reload +``` + +Then visit: + +- `http://localhost:8000/docs` — Swagger UI +- `http://localhost:8000/shield/flags` — flag management dashboard +- `http://localhost:8000/checkout?user_id=beta_tester_1` — targeted user (always `"on"`) +- `http://localhost:8000/checkout?user_id=anyone_else` — 20% rollout + +--- + +## Next step + +[**Reference: Feature Flags →**](../reference/feature-flags.md) diff --git a/docs/tutorial/installation.md b/docs/tutorial/installation.md index 962b982..828c7bd 100644 --- a/docs/tutorial/installation.md +++ b/docs/tutorial/installation.md @@ -22,6 +22,9 @@ uv add "api-shield[fastapi,cli]" # FastAPI + rate limiting uv add "api-shield[fastapi,rate-limit]" +# FastAPI + feature flags +uv add "api-shield[fastapi,flags]" + # Everything (FastAPI adapter, Redis, dashboard, CLI, admin, rate limiting) uv add "api-shield[all]" ``` @@ -44,6 +47,7 @@ pip install "api-shield[all]" | `admin` | Unified `ShieldAdmin` (dashboard + REST API) | Recommended for CLI support | | `cli` | `shield` command-line tool + httpx client | Operators managing routes from the terminal | | `rate-limit` | `limits` library for `@rate_limit` enforcement | Any app using rate limiting | +| `flags` | `openfeature-sdk` + `packaging` for the feature flag system | Any app using feature flags | | `all` | All of the above | Easiest option for most projects | --- @@ -83,6 +87,7 @@ SHIELD_SERVER_URL=http://localhost:8000/shield --- -## Next step +## Next steps -[**Tutorial: Your first decorator →**](first-decorator.md) +- [**Tutorial: Your first decorator →**](first-decorator.md) +- [**Tutorial: Feature Flags →**](feature-flags.md) diff --git a/docs/tutorial/rate-limiting.md b/docs/tutorial/rate-limiting.md index e10af19..0835b38 100644 --- a/docs/tutorial/rate-limiting.md +++ b/docs/tutorial/rate-limiting.md @@ -375,6 +375,61 @@ 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. + +--- + +## Per-service rate limit (multi-service) + +When running multiple services with Shield Server and `ShieldSDK`, you can set a rate limit that applies to **every route of one service** without decorating individual handlers. + +```bash +# Cap all payments-service routes at 1000 per minute per IP +shield srl set payments-service 1000/minute + +# Shared counter across all callers +shield srl set payments-service 5000/hour --key global + +# Pause and resume enforcement +shield srl disable payments-service +shield srl enable payments-service + +# Clear counters (policy stays in place) +shield srl reset payments-service +``` + +The service rate limit sits between the all-services global rate limit (`shield grl`) and individual per-route limits. A request passes through all three layers in order before reaching the handler. You can configure any combination of the three independently. + +From the dashboard: open the **Rate Limits** tab and filter to a service. A **Service Rate Limit** card appears above the policies table with controls to configure, pause, reset, and remove the policy. + +See [Per-service rate limit reference](../reference/rate-limiting.md#per-service-rate-limit) and the [`shield srl` CLI reference](../reference/cli.md#shield-srl--shield-service-rate-limit) for the full API. + --- ## Blocked requests log diff --git a/examples/fastapi/feature_flags.py b/examples/fastapi/feature_flags.py new file mode 100644 index 0000000..752505e --- /dev/null +++ b/examples/fastapi/feature_flags.py @@ -0,0 +1,439 @@ +"""FastAPI — Feature Flags Example. + +Demonstrates the full feature-flag API powered by OpenFeature: + + * Boolean / string / integer / float / JSON flag types + * Async evaluation (``await engine.flag_client.get_boolean_value(...)``) + * Sync evaluation (``engine.sync.flag_client.get_boolean_value(...)``) + * EvaluationContext — per-request targeting based on user attributes + * Individual targeting — specific users always get a fixed variation + * Targeting rules — serve variations based on plan, country, app_version + * Percentage rollout (fallthrough) — gradual feature release + * Kill-switch — disable a flag globally without redeploying + * Live event stream — watch evaluations in real time + +Prerequisites: + pip install api-shield[flags] + # or: + uv pip install "api-shield[flags]" + +Run: + uv run uvicorn examples.fastapi.feature_flags:app --reload + +Then visit: + http://localhost:8000/docs — Swagger UI + http://localhost:8000/shield/ — admin dashboard (login: admin / secret) + http://localhost:8000/shield/flags — flag management UI + +Exercise the endpoints: + # Boolean flag — new checkout flow (async route) + curl "http://localhost:8000/checkout?user_id=user_123" + + # Boolean flag — new checkout flow (sync/def route) + curl "http://localhost:8000/checkout/sync?user_id=user_123" + + # String flag — UI theme selection + curl "http://localhost:8000/theme?user_id=beta_user_1" + + # Integer flag — max results per page + curl "http://localhost:8000/search?user_id=pro_user_1&plan=pro" + + # Float flag — discount rate for a country segment + curl "http://localhost:8000/pricing?user_id=uk_user_1&country=GB" + + # JSON flag — feature configuration bundle + curl "http://localhost:8000/config?user_id=user_123" + + # Targeting: individual user always gets the beta variation + curl "http://localhost:8000/checkout?user_id=beta_tester_1" + + # Live event stream (SSE) — watch evaluations happen in real time + curl -N "http://localhost:8000/shield/api/flags/stream" + +CLI — manage flags without redeploying: + shield login admin # password: secret + shield flags list + shield flags get new-checkout + shield flags disable new-checkout # kill-switch + shield flags enable new-checkout # restore + shield flags stream # tail live evaluations + shield flags stream new-checkout # filter to one flag +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Request + +from shield.admin import ShieldAdmin +from shield.core.config import make_engine +from shield.core.feature_flags.models import ( + EvaluationContext, + FeatureFlag, + FlagType, + FlagVariation, + Operator, + RolloutVariation, + RuleClause, + TargetingRule, +) +from shield.fastapi import ( + ShieldMiddleware, + ShieldRouter, + apply_shield_to_openapi, +) + +# --------------------------------------------------------------------------- +# Engine setup +# --------------------------------------------------------------------------- + +engine = make_engine() +engine.use_openfeature() + +router = ShieldRouter(engine=engine) + + +# --------------------------------------------------------------------------- +# Seed flags at startup +# --------------------------------------------------------------------------- + + +async def _seed_flags() -> None: + """Register all feature flags. + + In production you would persist flags to a shared backend (Redis, file) + or manage them via the dashboard / REST API. This function is for + demonstration only — flags created here exist only in memory. + """ + + # ------------------------------------------------------------------ + # 1. Boolean flag — new checkout flow + # + # Individual targeting: beta_tester_1 always sees the new flow. + # Fallthrough: 20% of remaining users get "on", 80% get "off". + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="new-checkout", + name="New Checkout Flow", + description="Gradual rollout of the redesigned checkout experience.", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True, description="New flow enabled"), + FlagVariation(name="off", value=False, description="Legacy flow"), + ], + off_variation="off", + # 20 % rollout — weights out of 100_000 + fallthrough=[ + RolloutVariation(variation="on", weight=20_000), + RolloutVariation(variation="off", weight=80_000), + ], + targets={"on": ["beta_tester_1", "beta_tester_2"]}, + ), + audit=False, + ) + + # ------------------------------------------------------------------ + # 2. String flag — UI theme + # + # Rule: users whose email ends with "@acme.com" always get "dark". + # Fallthrough: everyone else gets "light". + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="ui-theme", + name="UI Theme", + description="Default UI theme served to users.", + type=FlagType.STRING, + variations=[ + FlagVariation(name="light", value="light"), + FlagVariation(name="dark", value="dark"), + FlagVariation(name="system", value="system"), + ], + off_variation="light", + fallthrough="light", + rules=[ + TargetingRule( + description="Corporate users → dark theme", + clauses=[ + RuleClause( + attribute="email", + operator=Operator.ENDS_WITH, + values=["@acme.com"], + ) + ], + variation="dark", + ) + ], + ), + audit=False, + ) + + # ------------------------------------------------------------------ + # 3. Integer flag — search results per page + # + # Rule: "pro" and "enterprise" plans get 50 results. + # Fallthrough: free-tier users get 10. + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="search-page-size", + name="Search Page Size", + description="Max results returned per search request.", + type=FlagType.INTEGER, + variations=[ + FlagVariation(name="small", value=10, description="Free tier"), + FlagVariation(name="large", value=50, description="Pro / enterprise"), + ], + off_variation="small", + fallthrough="small", + rules=[ + TargetingRule( + description="Paid plans → large page size", + clauses=[ + RuleClause( + attribute="plan", + operator=Operator.IN, + values=["pro", "enterprise"], + ) + ], + variation="large", + ) + ], + ), + audit=False, + ) + + # ------------------------------------------------------------------ + # 4. Float flag — regional discount rate + # + # Rule: GB users get a 15 % discount. + # Rule: EU users get a 10 % discount. + # Fallthrough: no discount (0.0). + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="discount-rate", + name="Regional Discount Rate", + description="Fractional discount applied at checkout (0.0 = none, 0.15 = 15%).", + type=FlagType.FLOAT, + variations=[ + FlagVariation(name="none", value=0.0), + FlagVariation(name="eu", value=0.10), + FlagVariation(name="gb", value=0.15), + ], + off_variation="none", + fallthrough="none", + rules=[ + TargetingRule( + description="GB → 15% discount", + clauses=[RuleClause(attribute="country", operator=Operator.IS, values=["GB"])], + variation="gb", + ), + TargetingRule( + description="EU → 10% discount", + clauses=[ + RuleClause( + attribute="country", + operator=Operator.IN, + values=["DE", "FR", "NL", "SE", "PL"], + ) + ], + variation="eu", + ), + ], + ), + audit=False, + ) + + # ------------------------------------------------------------------ + # 5. JSON flag — feature configuration bundle + # + # Returns a structured dict with multiple settings in one round-trip. + # Useful for feature bundles that require several related values. + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="feature-config", + name="Feature Configuration Bundle", + description="Combined config object for the new dashboard experience.", + type=FlagType.JSON, + variations=[ + FlagVariation( + name="v2", + value={ + "sidebar": True, + "analytics": True, + "export_formats": ["csv", "xlsx", "json"], + "max_widgets": 20, + }, + description="Full v2 dashboard", + ), + FlagVariation( + name="v1", + value={ + "sidebar": False, + "analytics": False, + "export_formats": ["csv"], + "max_widgets": 5, + }, + description="Legacy v1 dashboard", + ), + ], + off_variation="v1", + fallthrough="v1", + ), + audit=False, + ) + + +# --------------------------------------------------------------------------- +# Routes — async (def async) +# --------------------------------------------------------------------------- + + +@router.get("/checkout") +async def checkout(request: Request, user_id: str = "anonymous"): + """Async route: evaluate the boolean ``new-checkout`` flag. + + Pass ``?user_id=beta_tester_1`` to see individual targeting in action. + The flag is on a 20 % rollout for everyone else. + """ + ctx = EvaluationContext(key=user_id) + enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx) + return { + "user_id": user_id, + "new_checkout": enabled, + "flow": "v2" if enabled else "v1", + } + + +@router.get("/theme") +async def theme(request: Request, user_id: str = "anonymous", email: str = ""): + """Async route: evaluate the string ``ui-theme`` flag. + + Pass ``?email=you@acme.com`` to trigger the corporate-user rule. + """ + ctx = EvaluationContext(key=user_id, email=email or None) + selected_theme = await engine.flag_client.get_string_value("ui-theme", "light", ctx) + return {"user_id": user_id, "theme": selected_theme} + + +@router.get("/search") +async def search(request: Request, user_id: str = "anonymous", plan: str = "free"): + """Async route: evaluate the integer ``search-page-size`` flag. + + Pass ``?plan=pro`` or ``?plan=enterprise`` to get the larger page size. + """ + ctx = EvaluationContext(key=user_id, attributes={"plan": plan}) + page_size = await engine.flag_client.get_integer_value("search-page-size", 10, ctx) + return {"user_id": user_id, "plan": plan, "page_size": page_size, "results": []} + + +@router.get("/pricing") +async def pricing(request: Request, user_id: str = "anonymous", country: str = "US"): + """Async route: evaluate the float ``discount-rate`` flag. + + Pass ``?country=GB`` (15 %) or ``?country=DE`` (10 %). + """ + ctx = EvaluationContext(key=user_id, country=country) + discount = await engine.flag_client.get_float_value("discount-rate", 0.0, ctx) + return { + "user_id": user_id, + "country": country, + "discount_rate": discount, + "price_usd": round(100.0 * (1 - discount), 2), + } + + +@router.get("/config") +async def config(request: Request, user_id: str = "anonymous"): + """Async route: evaluate the JSON ``feature-config`` flag. + + Returns the entire configuration bundle in a single evaluation call. + """ + ctx = EvaluationContext(key=user_id) + cfg: Any = await engine.flag_client.get_object_value( + "feature-config", {"sidebar": False, "analytics": False}, ctx + ) + return {"user_id": user_id, "config": cfg} + + +# --------------------------------------------------------------------------- +# Routes — sync (def, no async) +# --------------------------------------------------------------------------- +# FastAPI runs plain ``def`` handlers in a thread pool. +# ``engine.sync.flag_client`` provides a thread-safe synchronous facade over +# the same OpenFeature client — no asyncio bridge needed because flag +# evaluation is pure Python with no I/O. +# --------------------------------------------------------------------------- + + +@router.get("/checkout/sync") +def checkout_sync(request: Request, user_id: str = "anonymous"): + """Sync route: evaluate the ``new-checkout`` flag from a ``def`` handler. + + Identical result to ``GET /checkout`` — use whichever matches your handler style. + """ + enabled = engine.sync.flag_client.get_boolean_value( + "new-checkout", False, {"targeting_key": user_id} + ) + return { + "user_id": user_id, + "new_checkout": enabled, + "flow": "v2" if enabled else "v1", + "evaluated_in": "sync", + } + + +@router.get("/search/sync") +def search_sync(request: Request, user_id: str = "anonymous", plan: str = "free"): + """Sync route: evaluate the ``search-page-size`` flag from a ``def`` handler.""" + page_size = engine.sync.flag_client.get_integer_value( + "search-page-size", 10, {"targeting_key": user_id, "plan": plan} + ) + return { + "user_id": user_id, + "plan": plan, + "page_size": page_size, + "evaluated_in": "sync", + } + + +# --------------------------------------------------------------------------- +# App assembly +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def lifespan(_: FastAPI): + await _seed_flags() + yield + + +app = FastAPI( + title="api-shield — Feature Flags Example", + description=( + "Demonstrates boolean, string, integer, float, and JSON flags with " + "targeting rules, rollouts, kill-switches, and live event streaming.\n\n" + "Requires `api-shield[flags]` (`pip install api-shield[flags]`)." + ), + lifespan=lifespan, +) + +app.add_middleware(ShieldMiddleware, engine=engine) +app.include_router(router) +apply_shield_to_openapi(app, engine) + +app.mount( + "/shield", + ShieldAdmin( + engine=engine, + auth=("admin", "secret"), + prefix="/shield", + # enable_flags is auto-detected from engine.use_openfeature() — no + # need to set it explicitly. Set to True/False to override. + ), +) diff --git a/examples/fastapi/multi_service.py b/examples/fastapi/multi_service.py new file mode 100644 index 0000000..a999563 --- /dev/null +++ b/examples/fastapi/multi_service.py @@ -0,0 +1,321 @@ +"""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 --with uvicorn uvicorn examples.fastapi.multi_service:shield_app --port 8001 --reload + + Payments service (port 8000): + uv run --with uvicorn uvicorn examples.fastapi.multi_service:payments_app --port 8000 --reload + + Orders service (port 8002): + uv run --with uvicorn 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, + setup_shield_docs, +) +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", + username="admin", + password="secret", + # 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) +setup_shield_docs(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", + username="admin", + password="secret", + # 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) +setup_shield_docs(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..bc0e556 --- /dev/null +++ b/examples/fastapi/shield_server.py @@ -0,0 +1,257 @@ +"""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", + password="secret", + # 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..ba7bd43 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,11 +98,13 @@ nav: - Adding Middleware: tutorial/middleware.md - Backends: tutorial/backends.md - Rate Limiting: tutorial/rate-limiting.md + - Feature Flags: tutorial/feature-flags.md - Admin Dashboard: tutorial/admin-dashboard.md - CLI: tutorial/cli.md - Reference: - Decorators: reference/decorators.md - Rate Limiting: reference/rate-limiting.md + - Feature Flags: reference/feature-flags.md - ShieldEngine: reference/engine.md - Backends: reference/backends.md - Middleware: reference/middleware.md @@ -110,8 +112,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/pyproject.toml b/pyproject.toml index 83e6a74..d187c7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api-shield" -version = "0.7.0" +version = "0.8.0" description = "Route lifecycle management for APIs — maintenance mode, env gating, deprecation, and more" readme = "README.md" license = { text = "MIT" } @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "pydantic>=2.0", "anyio>=4.0", - "starlette>=0.27", + "starlette>=0.40", ] [project.urls] @@ -33,7 +33,7 @@ Repository = "https://github.com/Attakay78/api-shield" Issues = "https://github.com/Attakay78/api-shield/issues" [project.optional-dependencies] -fastapi = ["fastapi>=0.100"] +fastapi = ["fastapi>=0.115"] redis = ["redis[asyncio]>=5.0"] dashboard = [ "jinja2>=3.1", @@ -49,8 +49,12 @@ admin = [ yaml = ["pyyaml>=6.0"] toml = ["tomli-w>=1.0"] rate-limit = ["limits>=5.8.0"] +flags = [ + "openfeature-sdk>=0.8", + "packaging>=23.0", +] all = [ - "fastapi>=0.100", + "fastapi>=0.115", "redis[asyncio]>=5.0", "jinja2>=3.1", "aiofiles>=23.0", @@ -61,6 +65,8 @@ all = [ "tomli-w>=1.0", "python-multipart>=0.0.22", "limits>=5.8.0", + "openfeature-sdk>=0.8", + "packaging>=23.0", ] docs = [ "mkdocs-material>=9.5", @@ -75,11 +81,13 @@ dev = [ "ruff", "mypy", "aiofiles>=23.0", - "fastapi>=0.100", + "fastapi>=0.115", "pre-commit>=3.7", "tomli-w>=1.0", "pyyaml>=6.0", "limits>=5.8.0", + "openfeature-sdk>=0.8", + "packaging>=23.0", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.25", "mkdocs-git-revision-date-localized-plugin>=1.2", @@ -106,6 +114,11 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP"] +[tool.ruff.lint.per-file-ignores] +"shield/core/feature_flags/__init__.py" = ["E402"] +"shield/core/feature_flags/hooks.py" = ["E402"] +"shield/core/feature_flags/provider.py" = ["E402"] + [tool.mypy] python_version = "3.11" strict = true diff --git a/shield/admin/api.py b/shield/admin/api.py index daa561b..a30495a 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]) @@ -350,6 +369,45 @@ async def global_disable_api(request: Request) -> JSONResponse: return JSONResponse(cfg.model_dump(mode="json")) +async def service_maintenance_get(request: Request) -> JSONResponse: + """GET /api/services/{service}/maintenance — current per-service maintenance config.""" + service = request.path_params["service"] + cfg = await _engine(request).get_service_maintenance(service) + return JSONResponse(cfg.model_dump(mode="json")) + + +async def service_maintenance_enable(request: Request) -> JSONResponse: + """POST /api/services/{service}/maintenance/enable — enable per-service maintenance.""" + service = request.path_params["service"] + actor = _actor(request) + try: + body = await request.json() + except Exception: + body = {} + reason = body.get("reason", "") if isinstance(body, dict) else "" + exempt = body.get("exempt_paths", []) if isinstance(body, dict) else [] + include_fa = body.get("include_force_active", False) if isinstance(body, dict) else False + cfg = await _engine(request).enable_service_maintenance( + service=service, + reason=reason, + exempt_paths=exempt, + include_force_active=include_fa, + actor=actor, + platform=_platform(request), + ) + return JSONResponse(cfg.model_dump(mode="json")) + + +async def service_maintenance_disable(request: Request) -> JSONResponse: + """POST /api/services/{service}/maintenance/disable — disable per-service maintenance.""" + service = request.path_params["service"] + actor = _actor(request) + cfg = await _engine(request).disable_service_maintenance( + service=service, actor=actor, platform=_platform(request) + ) + return JSONResponse(cfg.model_dump(mode="json")) + + # --------------------------------------------------------------------------- # Rate limits # --------------------------------------------------------------------------- @@ -408,6 +466,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 +569,623 @@ 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}) + + +# --------------------------------------------------------------------------- +# Per-service rate limit endpoints +# --------------------------------------------------------------------------- + + +async def get_service_rate_limit(request: Request) -> JSONResponse: + """GET /api/services/{service}/rate-limit — current per-service rate limit policy.""" + service = request.path_params["service"] + policy = await _engine(request).get_service_rate_limit(service) + if policy is None: + return JSONResponse({"enabled": False, "policy": None}) + return JSONResponse({"enabled": policy.enabled, "policy": policy.model_dump(mode="json")}) + + +async def set_service_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/services/{service}/rate-limit — set or update per-service rate limit.""" + service = request.path_params["service"] + engine = _engine(request) + actor = _actor(request) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "invalid JSON body"}, status_code=400) + + limit = body.get("limit", "") + if not limit: + return JSONResponse({"error": "limit is required"}, status_code=400) + exempt_routes = body.get("exempt_routes", []) + if not isinstance(exempt_routes, list): + return JSONResponse({"error": "exempt_routes must be a list"}, status_code=400) + + try: + policy = await engine.set_service_rate_limit( + service, + limit=limit, + algorithm=body.get("algorithm"), + key_strategy=body.get("key_strategy"), + on_missing_key=body.get("on_missing_key"), + burst=int(body.get("burst", 0)), + exempt_routes=exempt_routes, + actor=actor, + platform=_platform(request), + ) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + return JSONResponse(policy.model_dump(mode="json"), status_code=201) + + +async def delete_service_rate_limit_api(request: Request) -> JSONResponse: + """DELETE /api/services/{service}/rate-limit — remove per-service rate limit policy.""" + service = request.path_params["service"] + await _engine(request).delete_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + return JSONResponse({"ok": True}) + + +async def reset_service_rate_limit_api(request: Request) -> JSONResponse: + """DELETE /api/services/{service}/rate-limit/reset — reset per-service counters.""" + service = request.path_params["service"] + await _engine(request).reset_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + return JSONResponse({"ok": True}) + + +async def enable_service_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/services/{service}/rate-limit/enable — resume paused service rate limit.""" + service = request.path_params["service"] + await _engine(request).enable_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + return JSONResponse({"ok": True}) + + +async def disable_service_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/services/{service}/rate-limit/disable — pause service rate limit.""" + service = request.path_params["service"] + await _engine(request).disable_service_rate_limit( + service, actor=_actor(request), 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 _feed_flags() -> None: + try: + async for event in engine.backend.subscribe_flag_changes(): # type: ignore[attr-defined] + envelope = _json.dumps(event) + await queue.put(f"data: {envelope}\n\n") + except (NotImplementedError, AttributeError): + # Backend doesn't support flag pub/sub — silently skip. + pass + except asyncio.CancelledError: + raise + except Exception: + logger.exception("shield: SDK SSE flag subscription error") + + async def _generate() -> object: + tasks.append(asyncio.create_task(_feed_states())) + tasks.append(asyncio.create_task(_feed_rl_policies())) + tasks.append(asyncio.create_task(_feed_flags())) + try: + while True: + # Check for client disconnect before blocking on the queue. + # is_disconnected() polls receive() with a 1 ms timeout so it + # never blocks the loop for more than a millisecond. + if await request.is_disconnected(): + break + 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() + # Await the feeder tasks so their finally blocks (which deregister + # subscriber queues) run before this handler returns. Errors are + # suppressed — we only care that cleanup completes. + await asyncio.gather(*tasks, return_exceptions=True) + + 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}) + + +# --------------------------------------------------------------------------- +# Feature flag endpoints +# --------------------------------------------------------------------------- +# +# These endpoints are only mounted when ShieldAdmin(enable_flags=True). +# They require the [flags] optional extra to be installed — callers get a +# clear 501 error if the extra is missing. +# --------------------------------------------------------------------------- + + +def _flags_not_configured() -> JSONResponse: + return JSONResponse( + { + "error": ( + "Feature flags are not enabled. " + "Call engine.use_openfeature() and set enable_flags=True on ShieldAdmin." + ) + }, + status_code=501, + ) + + +def _flags_not_installed() -> JSONResponse: + return JSONResponse( + { + "error": ( + "Feature flags require the [flags] extra. " + "Install with: pip install api-shield[flags]" + ) + }, + status_code=501, + ) + + +def _flag_models_available() -> bool: + """Return True if the openfeature extra is installed.""" + try: + import openfeature # noqa: F401 + + return True + except ImportError: + return False + + +async def list_flags(request: Request) -> JSONResponse: + """GET /api/flags — list all feature flags.""" + if not _flag_models_available(): + return _flags_not_installed() + flags = await _engine(request).list_flags() + return JSONResponse([f.model_dump(mode="json") for f in flags]) + + +async def get_flag(request: Request) -> JSONResponse: + """GET /api/flags/{key} — get a single feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + return JSONResponse(flag.model_dump(mode="json")) + + +async def create_flag(request: Request) -> JSONResponse: + """POST /api/flags — create a new feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + try: + from shield.core.feature_flags.models import FeatureFlag + + flag = FeatureFlag.model_validate(body) + except Exception as exc: + return _err(f"Invalid flag definition: {exc}") + + # Conflict check + existing = await _engine(request).get_flag(flag.key) + if existing is not None: + return _err(f"Flag '{flag.key}' already exists. Use PUT to update.", 409) + + await _engine(request).save_flag(flag, actor=_actor(request), platform=_platform(request)) + return JSONResponse(flag.model_dump(mode="json"), status_code=201) + + +async def update_flag(request: Request) -> JSONResponse: + """PUT /api/flags/{key} — replace a feature flag (full update).""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + # Key in URL must match key in body if provided. + if isinstance(body, dict) and body.get("key", key) != key: + return _err("Flag key in URL and body must match") + + if isinstance(body, dict): + body["key"] = key + + try: + from shield.core.feature_flags.models import FeatureFlag + + flag = FeatureFlag.model_validate(body) + except Exception as exc: + return _err(f"Invalid flag definition: {exc}") + + await _engine(request).save_flag(flag, actor=_actor(request), platform=_platform(request)) + return JSONResponse(flag.model_dump(mode="json")) + + +async def patch_flag(request: Request) -> JSONResponse: + """PATCH /api/flags/{key} — partial update of a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + if not isinstance(body, dict): + return _err("Body must be a JSON object") + + # Never allow patching immutable fields + for immutable in ("key", "type"): + body.pop(immutable, None) + + try: + from shield.core.feature_flags.models import FeatureFlag + + # Build updated flag by merging patch onto existing + current = flag.model_dump(mode="python") + current.update(body) + updated = FeatureFlag.model_validate(current) + except Exception as exc: + return _err(f"Invalid patch: {exc}") + + # Cross-field validation: off_variation and string fallthrough must name + # an existing variation (the model doesn't enforce this itself). + variation_names = {v.name for v in updated.variations} + if updated.off_variation not in variation_names: + return _err(f"off_variation '{updated.off_variation}' does not match any variation name") + if isinstance(updated.fallthrough, str) and updated.fallthrough not in variation_names: + return _err(f"fallthrough '{updated.fallthrough}' does not match any variation name") + + await _engine(request).save_flag(updated, actor=_actor(request), platform=_platform(request)) + return JSONResponse(updated.model_dump(mode="json")) + + +async def enable_flag(request: Request) -> JSONResponse: + """POST /api/flags/{key}/enable — enable a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + flag = flag.model_copy(update={"enabled": True}) + await _engine(request).save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_enabled" + ) + return JSONResponse(flag.model_dump(mode="json")) + + +async def disable_flag(request: Request) -> JSONResponse: + """POST /api/flags/{key}/disable — disable a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + flag = flag.model_copy(update={"enabled": False}) + await _engine(request).save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_disabled" + ) + return JSONResponse(flag.model_dump(mode="json")) + + +async def delete_flag(request: Request) -> JSONResponse: + """DELETE /api/flags/{key} — delete a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + existing = await _engine(request).get_flag(key) + if existing is None: + return _err(f"Flag '{key}' not found", 404) + await _engine(request).delete_flag(key, actor=_actor(request), platform=_platform(request)) + return JSONResponse({"ok": True, "deleted": key}) + + +async def evaluate_flag(request: Request) -> JSONResponse: + """POST /api/flags/{key}/evaluate — evaluate a flag for a given context. + + Body: ``{"default": , "context": {"key": "user_1", "attributes": {...}}}`` + + Returns the resolved value, variation, reason, and any metadata. + Useful for debugging targeting rules from the dashboard or CLI. + """ + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + + try: + body = await request.json() + except Exception: + body = {} + + ctx_data = body.get("context", {}) if isinstance(body, dict) else {} + + try: + from shield.core.feature_flags.evaluator import FlagEvaluator + from shield.core.feature_flags.models import EvaluationContext + + ctx = EvaluationContext.model_validate({"key": "anonymous", **ctx_data}) + engine = _engine(request) + # Gather all flags and segments from the engine for prerequisite resolution. + all_flags_list = await engine.list_flags() + all_flags = {f.key: f for f in all_flags_list} + segments_list = await engine.list_segments() + segments = {s.key: s for s in segments_list} + + evaluator = FlagEvaluator(segments=segments) + result = evaluator.evaluate(flag, ctx, all_flags) + except Exception as exc: + return _err(f"Evaluation error: {exc}", 500) + + return JSONResponse( + { + "flag_key": key, + "value": result.value, + "variation": result.variation, + "reason": result.reason.value, + "rule_id": result.rule_id, + "prerequisite_key": result.prerequisite_key, + "error_message": result.error_message, + } + ) + + +# --------------------------------------------------------------------------- +# Segment endpoints +# --------------------------------------------------------------------------- + + +async def list_segments(request: Request) -> JSONResponse: + """GET /api/segments — list all segments.""" + if not _flag_models_available(): + return _flags_not_installed() + segments = await _engine(request).list_segments() + return JSONResponse([s.model_dump(mode="json") for s in segments]) + + +async def get_segment(request: Request) -> JSONResponse: + """GET /api/segments/{key} — get a single segment.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + segment = await _engine(request).get_segment(key) + if segment is None: + return _err(f"Segment '{key}' not found", 404) + return JSONResponse(segment.model_dump(mode="json")) + + +async def create_segment(request: Request) -> JSONResponse: + """POST /api/segments — create a new segment.""" + if not _flag_models_available(): + return _flags_not_installed() + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + try: + from shield.core.feature_flags.models import Segment + + segment = Segment.model_validate(body) + except Exception as exc: + return _err(f"Invalid segment definition: {exc}") + + existing = await _engine(request).get_segment(segment.key) + if existing is not None: + return _err(f"Segment '{segment.key}' already exists. Use PUT to update.", 409) + + await _engine(request).save_segment(segment, actor=_actor(request), platform=_platform(request)) + return JSONResponse(segment.model_dump(mode="json"), status_code=201) + + +async def update_segment(request: Request) -> JSONResponse: + """PUT /api/segments/{key} — replace a segment (full update).""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + if isinstance(body, dict) and body.get("key", key) != key: + return _err("Segment key in URL and body must match") + + if isinstance(body, dict): + body["key"] = key + + try: + from shield.core.feature_flags.models import Segment + + segment = Segment.model_validate(body) + except Exception as exc: + return _err(f"Invalid segment definition: {exc}") + + await _engine(request).save_segment(segment, actor=_actor(request), platform=_platform(request)) + return JSONResponse(segment.model_dump(mode="json")) + + +async def delete_segment(request: Request) -> JSONResponse: + """DELETE /api/segments/{key} — delete a segment.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + existing = await _engine(request).get_segment(key) + if existing is None: + return _err(f"Segment '{key}' not found", 404) + await _engine(request).delete_segment(key, actor=_actor(request), platform=_platform(request)) + return JSONResponse({"ok": True, "deleted": key}) diff --git a/shield/admin/app.py b/shield/admin/app.py index dd8d73a..d8db078 100644 --- a/shield/admin/app.py +++ b/shield/admin/app.py @@ -211,12 +211,78 @@ async def _logout(request: Request) -> Response: return response +def _flag_dashboard_modal_routes() -> list[Route]: + """Flag + segment modal routes that must be registered BEFORE the generic wildcard. + + These must appear before ``Route("/modal/{action}/{path_key}", ...)`` in the + route list so Starlette's first-match routing picks the specific handler. + """ + return [ + Route("/modal/flag/create", _dash.modal_flag_create, methods=["GET"]), + Route("/modal/flag/{key}/eval", _dash.modal_flag_eval, methods=["GET"]), + Route("/modal/segment/create", _dash.modal_segment_create, methods=["GET"]), + Route("/modal/segment/{key}/view", _dash.modal_segment_view, methods=["GET"]), + Route("/modal/segment/{key}", _dash.modal_segment_detail, methods=["GET"]), + ] + + +def _flag_dashboard_routes() -> list[Route]: + """Return the flag + segment dashboard UI routes for mounting in ShieldAdmin.""" + return [ + Route("/flags", _dash.flags_page, methods=["GET"]), + Route("/flags/rows", _dash.flags_rows_partial, methods=["GET"]), + Route("/flags/create", _dash.flag_create_form, methods=["POST"]), + Route("/flags/{key}", _dash.flag_detail_page, methods=["GET"]), + Route("/flags/{key}/settings/save", _dash.flag_settings_save, methods=["POST"]), + Route("/flags/{key}/variations/save", _dash.flag_variations_save, methods=["POST"]), + Route("/flags/{key}/targeting/save", _dash.flag_targeting_save, methods=["POST"]), + Route("/flags/{key}/prerequisites/save", _dash.flag_prerequisites_save, methods=["POST"]), + Route("/flags/{key}/targets/save", _dash.flag_targets_save, methods=["POST"]), + Route("/flags/{key}/enable", _dash.flag_enable, methods=["POST"]), + Route("/flags/{key}/disable", _dash.flag_disable, methods=["POST"]), + Route("/flags/{key}", _dash.flag_delete, methods=["DELETE"]), + Route("/flags/{key}/eval", _dash.flag_eval_form, methods=["POST"]), + Route("/segments", _dash.segments_page, methods=["GET"]), + Route("/segments/rows", _dash.segments_rows_partial, methods=["GET"]), + Route("/segments/create", _dash.segment_create_form, methods=["POST"]), + Route("/segments/{key}/rules/add", _dash.segment_rule_add, methods=["POST"]), + Route("/segments/{key}/rules/{rule_id}", _dash.segment_rule_delete, methods=["DELETE"]), + Route("/segments/{key}/save", _dash.segment_save_form, methods=["POST"]), + Route("/segments/{key}", _dash.modal_segment_detail, methods=["GET"]), + Route("/segments/{key}", _dash.segment_delete, methods=["DELETE"]), + ] + + +def _flag_routes() -> list[Route]: + """Return the flag + segment API routes for mounting in ShieldAdmin.""" + return [ + # ── Flags CRUD ─────────────────────────────────────────────── + Route("/api/flags", _api.list_flags, methods=["GET"]), + Route("/api/flags", _api.create_flag, methods=["POST"]), + Route("/api/flags/{key}", _api.get_flag, methods=["GET"]), + Route("/api/flags/{key}", _api.update_flag, methods=["PUT"]), + Route("/api/flags/{key}", _api.patch_flag, methods=["PATCH"]), + Route("/api/flags/{key}", _api.delete_flag, methods=["DELETE"]), + Route("/api/flags/{key}/enable", _api.enable_flag, methods=["POST"]), + Route("/api/flags/{key}/disable", _api.disable_flag, methods=["POST"]), + Route("/api/flags/{key}/evaluate", _api.evaluate_flag, methods=["POST"]), + # ── Segments CRUD ──────────────────────────────────────────── + Route("/api/segments", _api.list_segments, methods=["GET"]), + Route("/api/segments", _api.create_segment, methods=["POST"]), + Route("/api/segments/{key}", _api.get_segment, methods=["GET"]), + Route("/api/segments/{key}", _api.update_segment, methods=["PUT"]), + Route("/api/segments/{key}", _api.delete_segment, methods=["DELETE"]), + ] + + def ShieldAdmin( engine: ShieldEngine, auth: AuthConfig = None, token_expiry: int = 86400, + sdk_token_expiry: int = 31536000, secret_key: str | None = None, prefix: str = "/shield", + enable_flags: bool | None = None, ) -> ASGIApp: """Create the unified Shield admin ASGI app. @@ -234,8 +300,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 @@ -243,6 +314,13 @@ def ShieldAdmin( prefix: URL prefix at which the admin app is mounted. Must match the path passed to ``app.mount()``. Used to build correct redirects. + enable_flags: + When ``True``, mount the feature flag and segment dashboard UI and + REST API endpoints (``/flags/*``, ``/api/flags/*``, ``/api/segments/*``). + Requires ``engine.use_openfeature()`` to have been called and + ``api-shield[flags]`` to be installed. + When ``None`` (default), auto-detected: flags are enabled when + ``engine.use_openfeature()`` has been called. Returns ------- @@ -251,12 +329,52 @@ def ShieldAdmin( """ import base64 + # Auto-detect flags: enabled when engine.use_openfeature() has been called. + if enable_flags is None: + enable_flags = getattr(engine, "_flag_client", None) is not None + templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) templates.env.filters["encode_path"] = lambda p: ( base64.urlsafe_b64encode(p.encode()).decode().rstrip("=") ) 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", "") + # Translate internal sentinel keys to human-friendly labels + if raw == "__global__": + return "[Global Maintenance]" + if raw == "__global_rl__": + return "[Global Rate Limit]" + if raw.startswith("__shield:svc_global:") and raw.endswith("__"): + name = raw[len("__shield:svc_global:") : -2] + return f"[{name} Maintenance]" + if raw.startswith("__shield:svc_rl:") and raw.endswith("__"): + name = raw[len("__shield:svc_rl:") : -2] + return f"[{name} Rate Limit]" + 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 + templates.env.globals["flags_enabled"] = enable_flags + try: version = importlib.metadata.version("api-shield") except importlib.metadata.PackageNotFoundError: @@ -266,6 +384,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), ) @@ -281,10 +400,17 @@ def ShieldAdmin( Route("/routes", _dash.routes_partial), Route("/modal/global/enable", _dash.modal_global_enable), Route("/modal/global/disable", _dash.modal_global_disable), + Route("/modal/service/enable", _dash.modal_service_enable), + Route("/modal/service/disable", _dash.modal_service_disable), Route("/modal/global-rl", _dash.modal_global_rl), Route("/modal/global-rl/delete", _dash.modal_global_rl_delete), Route("/modal/global-rl/reset", _dash.modal_global_rl_reset), + Route("/modal/service-rl", _dash.modal_service_rl), + Route("/modal/service-rl/delete", _dash.modal_service_rl_delete), + Route("/modal/service-rl/reset", _dash.modal_service_rl_reset), Route("/modal/env/{path_key}", _dash.modal_env_gate), + # Flag/segment modals must come before the generic wildcard below. + *(_flag_dashboard_modal_routes() if enable_flags else []), Route("/modal/{action}/{path_key}", _dash.action_modal), Route( "/global-maintenance/enable", @@ -296,6 +422,16 @@ def ShieldAdmin( _dash.global_maintenance_disable, methods=["POST"], ), + Route( + "/service-maintenance/enable", + _dash.service_maintenance_enable, + methods=["POST"], + ), + Route( + "/service-maintenance/disable", + _dash.service_maintenance_disable, + methods=["POST"], + ), Route("/toggle/{path_key}", _dash.toggle, methods=["POST"]), Route("/disable/{path_key}", _dash.disable, methods=["POST"]), Route("/enable/{path_key}", _dash.enable, methods=["POST"]), @@ -310,15 +446,22 @@ 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"]), Route("/global-rl/reset", _dash.global_rl_reset, methods=["POST"]), Route("/global-rl/enable", _dash.global_rl_enable, methods=["POST"]), Route("/global-rl/disable", _dash.global_rl_disable, methods=["POST"]), + Route("/service-rl/set", _dash.service_rl_set, methods=["POST"]), + Route("/service-rl/delete", _dash.service_rl_delete, methods=["POST"]), + Route("/service-rl/reset", _dash.service_rl_reset, methods=["POST"]), + Route("/service-rl/enable", _dash.service_rl_enable, methods=["POST"]), + Route("/service-rl/disable", _dash.service_rl_disable, methods=["POST"]), Route("/events", _dash.events), # ── REST API (CLI) ──────────────────────────────────────────── Route("/api/auth/login", _api.auth_login, methods=["POST"]), @@ -344,6 +487,21 @@ def ShieldAdmin( Route("/api/global", _api.get_global, methods=["GET"]), Route("/api/global/enable", _api.global_enable_api, methods=["POST"]), Route("/api/global/disable", _api.global_disable_api, methods=["POST"]), + Route( + "/api/services/{service}/maintenance", + _api.service_maintenance_get, + methods=["GET"], + ), + Route( + "/api/services/{service}/maintenance/enable", + _api.service_maintenance_enable, + methods=["POST"], + ), + Route( + "/api/services/{service}/maintenance/disable", + _api.service_maintenance_disable, + methods=["POST"], + ), Route("/api/rate-limits", _api.list_rate_limits, methods=["GET"]), Route("/api/rate-limits", _api.set_rate_limit_policy_api, methods=["POST"]), Route("/api/rate-limits/hits", _api.get_rate_limit_hits, methods=["GET"]), @@ -383,6 +541,45 @@ def ShieldAdmin( _api.disable_global_rate_limit_api, methods=["POST"], ), + Route( + "/api/services/{service}/rate-limit", + _api.get_service_rate_limit, + methods=["GET"], + ), + Route( + "/api/services/{service}/rate-limit", + _api.set_service_rate_limit_api, + methods=["POST"], + ), + Route( + "/api/services/{service}/rate-limit", + _api.delete_service_rate_limit_api, + methods=["DELETE"], + ), + Route( + "/api/services/{service}/rate-limit/reset", + _api.reset_service_rate_limit_api, + methods=["DELETE"], + ), + Route( + "/api/services/{service}/rate-limit/enable", + _api.enable_service_rate_limit_api, + methods=["POST"], + ), + Route( + "/api/services/{service}/rate-limit/disable", + _api.disable_service_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"]), + # ── Feature flags (mounted only when enable_flags=True) ────── + *(_flag_dashboard_routes() if enable_flags else []), + *(_flag_routes() if enable_flags else []), ], ) @@ -393,6 +590,7 @@ def ShieldAdmin( starlette_app.state.version = version starlette_app.state.token_manager = token_manager starlette_app.state.auth_backend = auth_backend + starlette_app.state.flags_enabled = enable_flags # Wrap with auth middleware. return _AuthMiddleware(starlette_app, token_manager=token_manager, auth_backend=auth_backend) 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..ec1855d 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: @@ -239,6 +249,39 @@ async def global_disable(self) -> dict[str, Any]: resp = await c.post("/api/global/disable") return cast(dict[str, Any], self._check(resp)) + # ── Per-service maintenance ─────────────────────────────────────── + + async def service_maintenance_status(self, service: str) -> dict[str, Any]: + """GET /api/services/{service}/maintenance — current per-service maintenance config.""" + async with self._make_client() as c: + resp = await c.get(f"/api/services/{service}/maintenance") + return cast(dict[str, Any], self._check(resp)) + + async def service_maintenance_enable( + self, + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + ) -> dict[str, Any]: + """POST /api/services/{service}/maintenance/enable — enable per-service maintenance.""" + async with self._make_client() as c: + resp = await c.post( + f"/api/services/{service}/maintenance/enable", + json={ + "reason": reason, + "exempt_paths": exempt_paths or [], + "include_force_active": include_force_active, + }, + ) + return cast(dict[str, Any], self._check(resp)) + + async def service_maintenance_disable(self, service: str) -> dict[str, Any]: + """POST /api/services/{service}/maintenance/disable — disable per-service maintenance.""" + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/maintenance/disable") + return cast(dict[str, Any], self._check(resp)) + async def list_rate_limits(self) -> list[dict[str, Any]]: """GET /api/rate-limits — list all rate limit policies.""" async with self._make_client() as c: @@ -355,6 +398,156 @@ async def disable_global_rate_limit(self) -> dict[str, Any]: resp = await c.post("/api/global-rate-limit/disable") return cast(dict[str, Any], self._check(resp)) + # ── Per-service rate limit ───────────────────────────────────────── + + async def get_service_rate_limit(self, service: str) -> dict[str, Any]: + """GET /api/services/{service}/rate-limit — current per-service rate limit policy.""" + async with self._make_client() as c: + resp = await c.get(f"/api/services/{service}/rate-limit") + return cast(dict[str, Any], self._check(resp)) + + async def set_service_rate_limit( + self, + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + ) -> dict[str, Any]: + """POST /api/services/{service}/rate-limit — set or update per-service rate limit.""" + payload: dict[str, Any] = {"limit": limit, "burst": burst} + if algorithm: + payload["algorithm"] = algorithm + if key_strategy: + payload["key_strategy"] = key_strategy + if exempt_routes: + payload["exempt_routes"] = exempt_routes + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/rate-limit", json=payload) + return cast(dict[str, Any], self._check(resp)) + + async def delete_service_rate_limit(self, service: str) -> dict[str, Any]: + """DELETE /api/services/{service}/rate-limit — remove per-service rate limit policy.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/services/{service}/rate-limit") + return cast(dict[str, Any], self._check(resp)) + + async def reset_service_rate_limit(self, service: str) -> dict[str, Any]: + """DELETE /api/services/{service}/rate-limit/reset — reset per-service counters.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/services/{service}/rate-limit/reset") + return cast(dict[str, Any], self._check(resp)) + + async def enable_service_rate_limit(self, service: str) -> dict[str, Any]: + """POST /api/services/{service}/rate-limit/enable — resume paused service rate limit.""" + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/rate-limit/enable") + return cast(dict[str, Any], self._check(resp)) + + async def disable_service_rate_limit(self, service: str) -> dict[str, Any]: + """POST /api/services/{service}/rate-limit/disable — pause service rate limit.""" + async with self._make_client() as c: + resp = await c.post(f"/api/services/{service}/rate-limit/disable") + return cast(dict[str, Any], self._check(resp)) + + # ── Feature flags ───────────────────────────────────────────────── + + async def list_flags(self) -> list[dict[str, Any]]: + """GET /api/flags — list all feature flags.""" + async with self._make_client() as c: + resp = await c.get("/api/flags") + return cast(list[dict[str, Any]], self._check(resp)) + + async def get_flag(self, key: str) -> dict[str, Any]: + """GET /api/flags/{key} — get a single feature flag.""" + async with self._make_client() as c: + resp = await c.get(f"/api/flags/{key}") + return cast(dict[str, Any], self._check(resp)) + + async def create_flag(self, flag_data: dict[str, Any]) -> dict[str, Any]: + """POST /api/flags — create a new feature flag.""" + async with self._make_client() as c: + resp = await c.post("/api/flags", json=flag_data) + return cast(dict[str, Any], self._check(resp)) + + async def update_flag(self, key: str, flag_data: dict[str, Any]) -> dict[str, Any]: + """PUT /api/flags/{key} — replace a feature flag.""" + async with self._make_client() as c: + resp = await c.put(f"/api/flags/{key}", json=flag_data) + return cast(dict[str, Any], self._check(resp)) + + async def patch_flag(self, key: str, patch: dict[str, Any]) -> dict[str, Any]: + """PATCH /api/flags/{key} — partial update.""" + async with self._make_client() as c: + resp = await c.patch(f"/api/flags/{key}", json=patch) + return cast(dict[str, Any], self._check(resp)) + + async def enable_flag(self, key: str) -> dict[str, Any]: + """POST /api/flags/{key}/enable — enable a feature flag.""" + async with self._make_client() as c: + resp = await c.post(f"/api/flags/{key}/enable") + return cast(dict[str, Any], self._check(resp)) + + async def disable_flag(self, key: str) -> dict[str, Any]: + """POST /api/flags/{key}/disable — disable a feature flag.""" + async with self._make_client() as c: + resp = await c.post(f"/api/flags/{key}/disable") + return cast(dict[str, Any], self._check(resp)) + + async def delete_flag(self, key: str) -> dict[str, Any]: + """DELETE /api/flags/{key} — delete a feature flag.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/flags/{key}") + return cast(dict[str, Any], self._check(resp)) + + async def evaluate_flag( + self, + key: str, + context: dict[str, Any], + default: Any = None, + ) -> dict[str, Any]: + """POST /api/flags/{key}/evaluate — evaluate a flag for a context.""" + async with self._make_client() as c: + resp = await c.post( + f"/api/flags/{key}/evaluate", + json={"context": context, "default": default}, + ) + return cast(dict[str, Any], self._check(resp)) + + # ── Segments ────────────────────────────────────────────────────── + + async def list_segments(self) -> list[dict[str, Any]]: + """GET /api/segments — list all segments.""" + async with self._make_client() as c: + resp = await c.get("/api/segments") + return cast(list[dict[str, Any]], self._check(resp)) + + async def get_segment(self, key: str) -> dict[str, Any]: + """GET /api/segments/{key} — get a single segment.""" + async with self._make_client() as c: + resp = await c.get(f"/api/segments/{key}") + return cast(dict[str, Any], self._check(resp)) + + async def create_segment(self, segment_data: dict[str, Any]) -> dict[str, Any]: + """POST /api/segments — create a new segment.""" + async with self._make_client() as c: + resp = await c.post("/api/segments", json=segment_data) + return cast(dict[str, Any], self._check(resp)) + + async def update_segment(self, key: str, segment_data: dict[str, Any]) -> dict[str, Any]: + """PUT /api/segments/{key} — replace a segment.""" + async with self._make_client() as c: + resp = await c.put(f"/api/segments/{key}", json=segment_data) + return cast(dict[str, Any], self._check(resp)) + + async def delete_segment(self, key: str) -> dict[str, Any]: + """DELETE /api/segments/{key} — delete a segment.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/segments/{key}") + return cast(dict[str, Any], self._check(resp)) + def make_client( transport: httpx.AsyncBaseTransport | None = None, diff --git a/shield/cli/main.py b/shield/cli/main.py index ba2ae4b..3d306e5 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() @@ -569,12 +664,28 @@ async def _run_log() -> None: "rl_policy_updated": "update", "rl_reset": "reset", "rl_policy_deleted": "delete", + "flag_created": "created", + "flag_updated": "updated", + "flag_enabled": "enabled", + "flag_disabled": "disabled", + "flag_deleted": "deleted", + "segment_created": "created", + "segment_updated": "updated", + "segment_deleted": "deleted", } _rl_action_colours = { "rl_policy_set": "green", "rl_policy_updated": "yellow", "rl_reset": "cyan", "rl_policy_deleted": "red", + "flag_created": "green", + "flag_updated": "yellow", + "flag_enabled": "green", + "flag_disabled": "dim", + "flag_deleted": "red", + "segment_created": "green", + "segment_updated": "yellow", + "segment_deleted": "red", } table = Table(box=box.ROUNDED, show_header=True, header_style="bold") @@ -1245,5 +1356,1253 @@ async def _run_grl_disable() -> None: _run(_run_grl_disable) +# --------------------------------------------------------------------------- +# Per-service rate limit command group (shield service-rate-limit ...) +# --------------------------------------------------------------------------- + +srl_app = typer.Typer( + name="service-rate-limit", + help="Manage per-service rate limit policies applied to all routes of a service.", + no_args_is_help=True, +) +cli.add_typer(srl_app, name="service-rate-limit") +cli.add_typer(srl_app, name="srl") + + +@srl_app.command("get") +def srl_get( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Show the current per-service rate limit policy.""" + + async def _run_srl_get() -> None: + result = await make_client().get_service_rate_limit(service) + policy = result.get("policy") + if not policy: + console.print(f"[dim]No rate limit policy configured for {service!r}.[/dim]") + return + + table = Table(box=box.ROUNDED, show_header=True, header_style="bold") + table.add_column("Field", style="dim") + table.add_column("Value") + + table.add_row("Service", f"[cyan]{service}[/cyan]") + table.add_row("Limit", f"[magenta]{policy.get('limit', '—')}[/magenta]") + table.add_row("Algorithm", policy.get("algorithm", "—")) + table.add_row("Key Strategy", policy.get("key_strategy", "—")) + table.add_row("Burst", str(policy.get("burst", 0))) + table.add_row("Enabled", "[green]yes[/green]" if policy.get("enabled") else "[red]no[/red]") + exempt = policy.get("exempt_routes") or [] + table.add_row("Exempt Routes", "\n".join(exempt) if exempt else "[dim](none)[/dim]") + console.print(table) + + _run(_run_srl_get) + + +@srl_app.command("set") +def srl_set( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), + limit: str = typer.Argument(..., help="Rate limit string, e.g. 1000/minute"), + algorithm: str | None = typer.Option( + None, + "--algorithm", + "-a", + help="Algorithm: fixed_window, sliding_window, moving_window, token_bucket.", + ), + key_strategy: str | None = typer.Option( + None, + "--key", + "-k", + help="Key strategy: ip, user, api_key, global, custom.", + ), + burst: int = typer.Option(0, "--burst", "-b", help="Burst allowance (extra requests)."), + exempt: list[str] | None = typer.Option( + None, + "--exempt", + "-e", + help=( + "Route to exempt from the service limit. Repeat for multiple routes. " + "Use /path to exempt all methods, or METHOD:/path for a specific method." + ), + ), +) -> None: + """Set or update the per-service rate limit policy. + + The policy applies to every route of SERVICE that is not explicitly exempted. + Persisted to the backend so it survives restarts. Examples: + + \b + shield srl set payments-service 1000/minute + shield srl set orders-service 500/minute --key ip --exempt /health + shield srl set auth-service 200/hour --algorithm sliding_window --burst 20 + """ + + async def _run_srl_set() -> None: + result = await make_client().set_service_rate_limit( + service, + limit=limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + exempt_routes=list(exempt) if exempt else [], + ) + key_strat = result.get("key_strategy", "ip") + algo = result.get("algorithm", "") + console.print( + f"[green]✓[/green] Service rate limit set for [cyan]{service}[/cyan]: " + f"[bold]{result.get('limit')}[/bold] ({algo}, key={key_strat})" + ) + exempt_list = result.get("exempt_routes") or [] + if exempt_list: + console.print(f" Exempt routes: [dim]{', '.join(exempt_list)}[/dim]") + + _run(_run_srl_set) + + +@srl_app.command("delete") +def srl_delete( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Remove the per-service rate limit policy. + + Clears the policy from the backend. In-process counters are not + affected — use ``srl reset`` to clear them too. + """ + + async def _run_srl_delete() -> None: + result = await make_client().delete_service_rate_limit(service) + if result.get("ok"): + console.print( + f"[green]✓[/green] Service rate limit policy removed for [cyan]{service}[/cyan]." + ) + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_delete) + + +@srl_app.command("reset") +def srl_reset( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Reset per-service rate limit counters. + + Clears all counters so the limit starts fresh. The policy itself + is not removed — use ``srl delete`` for that. + """ + + async def _run_srl_reset() -> None: + result = await make_client().reset_service_rate_limit(service) + if result.get("ok"): + console.print(f"[green]✓[/green] Rate limit counters reset for [cyan]{service}[/cyan].") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_reset) + + +@srl_app.command("enable") +def srl_enable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Resume a paused per-service rate limit policy.""" + + async def _run_srl_enable() -> None: + result = await make_client().enable_service_rate_limit(service) + if result.get("ok"): + console.print( + f"[green]✓[/green] Service rate limit resumed for [cyan]{service}[/cyan]." + ) + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_enable) + + +@srl_app.command("disable") +def srl_disable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Pause the per-service rate limit policy without removing it.""" + + async def _run_srl_disable() -> None: + result = await make_client().disable_service_rate_limit(service) + if result.get("ok"): + console.print(f"[green]✓[/green] Service rate limit paused for [cyan]{service}[/cyan].") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_srl_disable) + + +# --------------------------------------------------------------------------- +# Per-service maintenance command group (shield sm ...) +# --------------------------------------------------------------------------- + +sm_app = typer.Typer( + name="service-maintenance", + help="Manage per-service maintenance mode (blocks all routes of one service).", + no_args_is_help=True, +) +cli.add_typer(sm_app, name="service-maintenance") +cli.add_typer(sm_app, name="sm") + + +@sm_app.command("status") +def sm_status( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Show the current maintenance configuration for a service.""" + + async def _run_sm_status() -> None: + cfg = await make_client().service_maintenance_status(service) + state_str = "[green]OFF[/green]" + if cfg.get("enabled"): + state_str = "[yellow]ON[/yellow]" + console.print(f"\n Service maintenance ({service}): {state_str}") + if cfg.get("enabled"): + console.print(f" Reason : {cfg.get('reason') or '—'}") + fa = cfg.get("include_force_active", False) + fa_colour = "red" if fa else "green" + fa_text = "yes" if fa else "no" + console.print(f" Include @force_active: [{fa_colour}]{fa_text}[/{fa_colour}]") + exempts = cfg.get("exempt_paths") or [] + if exempts: + console.print(" Exempt paths :") + for p in exempts: + console.print(f" • {p}") + else: + console.print(" Exempt paths : (none)") + console.print() + + _run(_run_sm_status) + + +@sm_app.command("enable") +def sm_enable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), + reason: str = typer.Option("", "--reason", "-r", help="Reason shown in 503 responses."), + exempt: list[str] | None = typer.Option( + None, + "--exempt", + "-e", + help="Route to exempt (repeat for multiple). Use /path or METHOD:/path.", + ), + include_force_active: bool = typer.Option( + False, + "--include-force-active/--no-include-force-active", + help="Also block @force_active routes.", + ), +) -> None: + """Enable maintenance mode for all routes of a service. + + All routes belonging to SERVICE return 503 until maintenance is disabled. + Exempt paths bypass the block and respond normally. + + \b + shield sm enable payments-service --reason "DB migration" + shield sm enable payments-service --reason "Upgrade" --exempt /health + shield sm enable orders-service --include-force-active + """ + + async def _run_sm_enable() -> None: + cfg = await make_client().service_maintenance_enable( + service, + reason=reason, + exempt_paths=list(exempt) if exempt else [], + include_force_active=include_force_active, + ) + console.print( + f"[yellow]⚠[/yellow] Service maintenance [yellow]ENABLED[/yellow]" + f" for [cyan]{service}[/cyan]" + ) + if cfg.get("reason"): + console.print(f" Reason: {cfg['reason']}") + if cfg.get("exempt_paths"): + console.print(f" Exempt: {', '.join(cfg['exempt_paths'])}") + if cfg.get("include_force_active"): + console.print(" [red]@force_active routes are also blocked.[/red]") + + _run(_run_sm_enable) + + +@sm_app.command("disable") +def sm_disable( + service: str = typer.Argument(..., help="Service name, e.g. payments-service"), +) -> None: + """Disable service maintenance mode, restoring normal per-route state.""" + + async def _run_sm_disable() -> None: + await make_client().service_maintenance_disable(service) + console.print( + f"[green]✓[/green] Service maintenance [green]DISABLED[/green]" + f" for [cyan]{service}[/cyan]" + ) + + _run(_run_sm_disable) + + +# --------------------------------------------------------------------------- +# Feature flags command group (shield flags ...) +# --------------------------------------------------------------------------- + +_FLAG_TYPE_COLOURS = { + "boolean": "green", + "string": "cyan", + "integer": "blue", + "float": "blue", + "json": "magenta", +} + +flags_app = typer.Typer( + name="flags", + help="Manage feature flags.", + no_args_is_help=True, +) +cli.add_typer(flags_app, name="flags") + + +def _flag_status_colour(enabled: bool) -> str: + return "green" if enabled else "dim" + + +def _print_flags_table(flags: list[dict[str, Any]]) -> None: + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False) + tbl.add_column("Key", style="bold cyan", no_wrap=True) + tbl.add_column("Type", style="white") + tbl.add_column("Status", style="white") + tbl.add_column("Variations", style="dim") + tbl.add_column("Fallthrough", style="dim") + for f in flags: + enabled = f.get("enabled", True) + status_text = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]" + ftype = f.get("type", "") + colour = _FLAG_TYPE_COLOURS.get(ftype, "white") + variations = ", ".join(v["name"] for v in f.get("variations", [])) + fallthrough = f.get("fallthrough", "") + if isinstance(fallthrough, list): + fallthrough = "rollout" + tbl.add_row( + f.get("key", ""), + f"[{colour}]{ftype}[/{colour}]", + status_text, + variations, + str(fallthrough), + ) + console.print(tbl) + + +@flags_app.command("list") +def flags_list( + type: str = typer.Option("", "--type", "-t", help="Filter by flag type (boolean, string, …)"), + enabled: str = typer.Option("", "--status", "-s", help="Filter by status: enabled or disabled"), +) -> None: + """List all feature flags.""" + + async def _run_flags_list() -> None: + flags = await make_client().list_flags() + if type: + flags = [f for f in flags if f.get("type") == type] + if enabled == "enabled": + flags = [f for f in flags if f.get("enabled", True)] + elif enabled == "disabled": + flags = [f for f in flags if not f.get("enabled", True)] + if not flags: + console.print("[dim]No flags found.[/dim]") + return + _print_flags_table(flags) + console.print(f"[dim]{len(flags)} flag(s)[/dim]") + + _run(_run_flags_list) + + +@flags_app.command("get") +def flags_get(key: str = typer.Argument(..., help="Flag key")) -> None: + """Show details for a single feature flag.""" + + async def _run_flags_get() -> None: + flag = await make_client().get_flag(key) + console.print(f"[bold cyan]{flag['key']}[/bold cyan] [dim]{flag.get('name', '')}[/dim]") + ftype = flag.get("type", "") + colour = _FLAG_TYPE_COLOURS.get(ftype, "white") + enabled = flag.get("enabled", True) + status_text = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]" + console.print(f" Type: [{colour}]{ftype}[/{colour}]") + console.print(f" Status: {status_text}") + console.print(f" Off variation: [dim]{flag.get('off_variation', '')}[/dim]") + fallthrough = flag.get("fallthrough", "") + if isinstance(fallthrough, list): + parts = [f"{rv['variation']}:{rv['weight'] // 1000}%" for rv in fallthrough] + console.print(f" Fallthrough: [dim]{', '.join(parts)}[/dim]") + else: + console.print(f" Fallthrough: [dim]{fallthrough}[/dim]") + # Variations + console.print(" Variations:") + for v in flag.get("variations", []): + console.print(f" • [bold]{v['name']}[/bold] = {v['value']!r}") + # Rules + rules = flag.get("rules") or [] + if rules: + console.print(f" Rules: [dim]{len(rules)} targeting rule(s)[/dim]") + # Prerequisites + prereqs = flag.get("prerequisites") or [] + if prereqs: + console.print(" Prerequisites:") + for p in prereqs: + console.print( + f" • [cyan]{p['flag_key']}[/cyan] must be [bold]{p['variation']}[/bold]" + ) + + _run(_run_flags_get) + + +@flags_app.command("create") +def flags_create( + key: str = typer.Argument(..., help="Unique flag key (e.g. new_checkout)"), + name: str = typer.Option(..., "--name", "-n", help="Human-readable name"), + type: str = typer.Option( + "boolean", "--type", "-t", help="Flag type: boolean, string, integer, float, json" + ), + description: str = typer.Option("", "--description", "-d", help="Optional description"), +) -> None: + """Create a new boolean feature flag with on/off variations. + + For other types or advanced configuration, use the dashboard or the API + directly. The flag is created enabled with fallthrough=off. + + \b + shield flags create new_checkout --name "New Checkout Flow" + shield flags create dark_mode --name "Dark Mode" --type boolean + """ + + async def _run_flags_create() -> None: + flag_type = type.lower() + # Build default on/off variations based on type. + if flag_type == "boolean": + variations = [{"name": "on", "value": True}, {"name": "off", "value": False}] + off_variation = "off" + fallthrough = "off" + elif flag_type == "string": + variations = [ + {"name": "control", "value": "control"}, + {"name": "treatment", "value": "treatment"}, + ] + off_variation = "control" + fallthrough = "control" + elif flag_type in ("integer", "float"): + variations = [{"name": "off", "value": 0}, {"name": "on", "value": 1}] + off_variation = "off" + fallthrough = "off" + elif flag_type == "json": + variations = [{"name": "off", "value": {}}, {"name": "on", "value": {}}] + off_variation = "off" + fallthrough = "off" + else: + err_console.print( + f"[red]Error:[/red] Unknown type {type!r}. " + "Use boolean, string, integer, float, or json." + ) + raise typer.Exit(code=1) + + flag_data = { + "key": key, + "name": name, + "type": flag_type, + "description": description, + "variations": variations, + "off_variation": off_variation, + "fallthrough": fallthrough, + "enabled": True, + } + result = await make_client().create_flag(flag_data) + console.print(f"[green]✓[/green] Flag [bold cyan]{result['key']}[/bold cyan] created.") + + _run(_run_flags_create) + + +@flags_app.command("enable") +def flags_enable(key: str = typer.Argument(..., help="Flag key")) -> None: + """Enable a feature flag.""" + + async def _run_flags_enable() -> None: + result = await make_client().enable_flag(key) + console.print(f"[green]✓[/green] Flag [bold cyan]{result['key']}[/bold cyan] enabled.") + + _run(_run_flags_enable) + + +@flags_app.command("disable") +def flags_disable(key: str = typer.Argument(..., help="Flag key")) -> None: + """Disable a feature flag (serves the off variation to all users).""" + + async def _run_flags_disable() -> None: + result = await make_client().disable_flag(key) + console.print(f"[dim]✓ Flag {result['key']} disabled.[/dim]") + + _run(_run_flags_disable) + + +@flags_app.command("delete") +def flags_delete( + key: str = typer.Argument(..., help="Flag key"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Permanently delete a feature flag.""" + if not yes: + typer.confirm(f"Delete flag '{key}'? This cannot be undone.", abort=True) + + async def _run_flags_delete() -> None: + result = await make_client().delete_flag(key) + console.print(f"[green]✓[/green] Flag [bold]{result['deleted']}[/bold] deleted.") + + _run(_run_flags_delete) + + +@flags_app.command("eval") +def flags_eval( + key: str = typer.Argument(..., help="Flag key"), + ctx_key: str = typer.Option("anonymous", "--key", "-k", help="Context key (user ID)"), + kind: str = typer.Option("user", "--kind", help="Context kind"), + attr: list[str] = typer.Option([], "--attr", "-a", help="Attribute as key=value (repeatable)"), +) -> None: + """Evaluate a feature flag for a given context (debug tool). + + \b + shield flags eval new_checkout --key user_123 --attr role=admin --attr plan=pro + """ + + async def _run_flags_eval() -> None: + attributes: dict[str, str] = {} + for a in attr: + if "=" not in a: + err_console.print(f"[red]Error:[/red] Attribute must be key=value, got: {a!r}") + raise typer.Exit(code=1) + k, _, v = a.partition("=") + attributes[k.strip()] = v.strip() + + context = {"key": ctx_key, "kind": kind, "attributes": attributes} + result = await make_client().evaluate_flag(key, context) + + value = result.get("value") + variation = result.get("variation", "") + reason = result.get("reason", "") + rule_id = result.get("rule_id") + + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False, show_header=False) + tbl.add_column("Field", style="dim", no_wrap=True) + tbl.add_column("Value", style="bold") + tbl.add_row("value", str(value)) + tbl.add_row("variation", variation or "—") + tbl.add_row("reason", reason) + if rule_id: + tbl.add_row("rule_id", rule_id) + prereq = result.get("prerequisite_key") + if prereq: + tbl.add_row("prerequisite_key", prereq) + err_msg = result.get("error_message") + if err_msg: + tbl.add_row("error", f"[red]{err_msg}[/red]") + console.print(tbl) + + _run(_run_flags_eval) + + +@flags_app.command("edit") +def flags_edit( + key: str = typer.Argument(..., help="Flag key"), + name: str | None = typer.Option(None, "--name", "-n", help="New display name"), + description: str | None = typer.Option(None, "--description", "-d", help="New description"), + off_variation: str | None = typer.Option( + None, "--off-variation", help="Variation served when flag is disabled" + ), + fallthrough: str | None = typer.Option( + None, "--fallthrough", help="Default variation when no rule matches" + ), +) -> None: + """Patch a feature flag (partial update — only provided fields are changed). + + \b + shield flags edit dark_mode --name "Dark Mode v2" + shield flags edit dark_mode --off-variation off --fallthrough control + """ + + async def _run_flags_edit() -> None: + patch: dict[str, Any] = {} + if name is not None: + patch["name"] = name + if description is not None: + patch["description"] = description + if off_variation is not None: + patch["off_variation"] = off_variation + if fallthrough is not None: + patch["fallthrough"] = fallthrough + if not patch: + err_console.print("[yellow]Nothing to update — provide at least one option.[/yellow]") + raise typer.Exit(1) + result = await make_client().patch_flag(key, patch) + console.print(f"[green]✓[/green] Flag [bold cyan]{result['key']}[/bold cyan] updated.") + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False, show_header=False) + tbl.add_column("Field", style="dim", no_wrap=True) + tbl.add_column("Value", style="bold") + for field in ("name", "description", "off_variation", "fallthrough"): + if field in patch: + val = result.get(field) + tbl.add_row(field, str(val) if val is not None else "—") + console.print(tbl) + + _run(_run_flags_edit) + + +@flags_app.command("variations") +def flags_variations(key: str = typer.Argument(..., help="Flag key")) -> None: + """List variations for a feature flag.""" + + async def _run_flags_variations() -> None: + flag = await make_client().get_flag(key) + variations = flag.get("variations") or [] + if not variations: + console.print(f"[dim]No variations for flag '{key}'.[/dim]") + return + tbl = Table(box=box.ROUNDED, show_header=True, header_style="bold") + tbl.add_column("Name", style="bold cyan", no_wrap=True) + tbl.add_column("Value", style="white") + tbl.add_column("Description", style="dim") + tbl.add_column("Role", style="dim") + off_var = flag.get("off_variation", "") + fallthrough = flag.get("fallthrough") + for v in variations: + vname = v.get("name", "") + role = "" + if vname == off_var: + role = "[slate]off[/slate]" + elif isinstance(fallthrough, str) and vname == fallthrough: + role = "[magenta]fallthrough[/magenta]" + tbl.add_row(vname, str(v.get("value", "")), v.get("description") or "—", role) + console.print(f"[bold cyan]{flag['key']}[/bold cyan] [dim]{flag.get('type', '')}[/dim]") + console.print(tbl) + + _run(_run_flags_variations) + + +@flags_app.command("targeting") +def flags_targeting(key: str = typer.Argument(..., help="Flag key")) -> None: + """Show targeting rules for a feature flag (read-only view).""" + + async def _run_flags_targeting() -> None: + flag = await make_client().get_flag(key) + rules = flag.get("rules") or [] + + off_var = flag.get("off_variation", "—") + ft = flag.get("fallthrough", "—") + console.print( + f"[bold cyan]{flag['key']}[/bold cyan]" + f" off=[cyan]{off_var}[/cyan]" + f" fallthrough=[cyan]{ft}[/cyan]" + ) + + if not rules: + console.print("[dim]No targeting rules.[/dim]") + return + + for i, rule in enumerate(rules): + desc = rule.get("description") or "" + variation = rule.get("variation") or "—" + clauses = rule.get("clauses") or [] + console.print( + f"\n [bold]Rule {i + 1}[/bold]" + + (f" — {desc}" if desc else "") + + f" → [green]{variation}[/green]" + ) + console.print(f" [dim]id: {rule.get('id', '')}[/dim]") + for clause in clauses: + attr = clause.get("attribute", "") + op = clause.get("operator", "") + vals = clause.get("values") or [] + negate = clause.get("negate", False) + neg_str = "[dim]NOT[/dim] " if negate else "" + vals_str = ", ".join(str(v) for v in vals) + console.print(f" {neg_str}[cyan]{attr}[/cyan] [dim]{op}[/dim] {vals_str}") + + _run(_run_flags_targeting) + + +@flags_app.command("add-rule") +def flags_add_rule( + key: str = typer.Argument(..., help="Flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation to serve when rule matches" + ), + segment: str | None = typer.Option( + None, "--segment", "-s", help="Segment key (adds an in_segment clause)" + ), + attribute: str | None = typer.Option( + None, "--attribute", "-a", help="Attribute name for a custom clause" + ), + operator: str = typer.Option( + "is", "--operator", "-o", help="Operator (e.g. is, in_segment, contains)" + ), + values: str | None = typer.Option(None, "--values", help="Comma-separated clause values"), + description: str = typer.Option("", "--description", "-d", help="Optional rule description"), + negate: bool = typer.Option(False, "--negate", help="Negate the clause result"), +) -> None: + """Add a targeting rule to a feature flag. + + \b + Segment-based rule (most common): + shield flags add-rule my-flag --variation on --segment beta-users + + Custom attribute rule: + shield flags add-rule my-flag --variation on \ + --attribute plan --operator is --values pro,enterprise + """ + if segment is None and attribute is None: + console.print("[red]Error:[/red] provide --segment or --attribute.") + raise typer.Exit(1) + if segment is not None and attribute is not None: + console.print("[red]Error:[/red] --segment and --attribute are mutually exclusive.") + raise typer.Exit(1) + + async def _run_add_rule() -> None: + client = make_client() + flag = await client.get_flag(key) + rules = list(flag.get("rules") or []) + + if segment is not None: + clause = { + "attribute": "key", + "operator": "in_segment", + "values": [segment], + "negate": negate, + } + else: + raw_vals: list[Any] = [v.strip() for v in (values or "").split(",") if v.strip()] + clause = { + "attribute": attribute, + "operator": operator, + "values": raw_vals, + "negate": negate, + } + + import uuid as _uuid + + new_rule: dict[str, Any] = { + "id": str(_uuid.uuid4()), + "description": description, + "clauses": [clause], + "variation": variation, + } + rules.append(new_rule) + await client.patch_flag(key, {"rules": rules}) + clause_summary = ( + f"in_segment [cyan]{segment}[/cyan]" + if segment is not None + else f"[cyan]{attribute}[/cyan] [dim]{operator}[/dim] {values}" + ) + console.print( + f"[green]✓[/green] Rule added to [bold cyan]{key}[/bold cyan]: " + f"{clause_summary} → [green]{variation}[/green]" + ) + console.print(f" [dim]id: {new_rule['id']}[/dim]") + + _run(_run_add_rule) + + +@flags_app.command("remove-rule") +def flags_remove_rule( + key: str = typer.Argument(..., help="Flag key"), + rule_id: str = typer.Option(..., "--rule-id", "-r", help="Rule ID to remove"), +) -> None: + """Remove a targeting rule from a feature flag by its ID. + + \b + shield flags remove-rule my-flag --rule-id + + Use 'shield flags targeting my-flag' to list rule IDs. + """ + + async def _run_remove_rule() -> None: + client = make_client() + flag = await client.get_flag(key) + rules = list(flag.get("rules") or []) + original_len = len(rules) + rules = [r for r in rules if r.get("id") != rule_id] + if len(rules) == original_len: + console.print(f"[red]Error:[/red] no rule with id '{rule_id}' found on flag '{key}'.") + raise typer.Exit(1) + await client.patch_flag(key, {"rules": rules}) + console.print( + f"[green]✓[/green] Rule [dim]{rule_id}[/dim] removed from [bold cyan]{key}[/bold cyan]." + ) + + _run(_run_remove_rule) + + +# --------------------------------------------------------------------------- +# Prerequisites commands (shield flags add-prereq / remove-prereq) +# --------------------------------------------------------------------------- + + +@flags_app.command("add-prereq") +def flags_add_prereq( + key: str = typer.Argument(..., help="Flag key"), + prereq_flag: str = typer.Option(..., "--flag", "-f", help="Prerequisite flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation the prerequisite flag must return" + ), +) -> None: + """Add a prerequisite flag to a feature flag. + + \b + shield flags add-prereq my-flag --flag auth-flag --variation on + + The prerequisite flag must evaluate to the given variation before this + flag's rules run. If it doesn't, this flag serves its off_variation. + """ + + async def _run_add_prereq() -> None: + client = make_client() + flag = await client.get_flag(key) + if flag["key"] == prereq_flag: + console.print("[red]Error:[/red] a flag cannot be its own prerequisite.") + raise typer.Exit(1) + prereqs = list(flag.get("prerequisites") or []) + # avoid duplicates + for p in prereqs: + if p.get("flag_key") == prereq_flag: + console.print( + f"[yellow]Warning:[/yellow] prerequisite [cyan]{prereq_flag}[/cyan]" + " already exists. Updating variation." + ) + p["variation"] = variation + await client.patch_flag(key, {"prerequisites": prereqs}) + console.print( + f"[green]✓[/green] Prerequisite [cyan]{prereq_flag}[/cyan]" + f" updated → must be [green]{variation}[/green]." + ) + return + prereqs.append({"flag_key": prereq_flag, "variation": variation}) + await client.patch_flag(key, {"prerequisites": prereqs}) + console.print( + f"[green]✓[/green] Prerequisite [cyan]{prereq_flag}[/cyan]" + f" added to [bold cyan]{key}[/bold cyan]:" + f" must be [green]{variation}[/green]." + ) + + _run(_run_add_prereq) + + +@flags_app.command("remove-prereq") +def flags_remove_prereq( + key: str = typer.Argument(..., help="Flag key"), + prereq_flag: str = typer.Option(..., "--flag", "-f", help="Prerequisite flag key to remove"), +) -> None: + """Remove a prerequisite from a feature flag. + + \b + shield flags remove-prereq my-flag --flag auth-flag + """ + + async def _run_remove_prereq() -> None: + client = make_client() + flag = await client.get_flag(key) + prereqs = list(flag.get("prerequisites") or []) + original_len = len(prereqs) + prereqs = [p for p in prereqs if p.get("flag_key") != prereq_flag] + if len(prereqs) == original_len: + console.print( + f"[red]Error:[/red] prerequisite [cyan]{prereq_flag}[/cyan]" + f" not found on flag [cyan]{key}[/cyan]." + ) + raise typer.Exit(1) + await client.patch_flag(key, {"prerequisites": prereqs}) + console.print( + f"[green]✓[/green] Prerequisite [cyan]{prereq_flag}[/cyan]" + f" removed from [bold cyan]{key}[/bold cyan]." + ) + + _run(_run_remove_prereq) + + +# --------------------------------------------------------------------------- +# Individual targets commands (shield flags target / untarget) +# --------------------------------------------------------------------------- + + +@flags_app.command("target") +def flags_target( + key: str = typer.Argument(..., help="Flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation to serve to the context keys" + ), + context_keys: str = typer.Option( + ..., "--keys", "-k", help="Comma-separated context keys to pin" + ), +) -> None: + """Pin context keys to always receive a specific variation. + + \b + shield flags target my-flag --variation on --keys user_123,user_456 + + Individual targets are evaluated before rules — highest priority targeting. + """ + + async def _run_target() -> None: + client = make_client() + flag = await client.get_flag(key) + variation_names = [v["name"] for v in (flag.get("variations") or [])] + if variation not in variation_names: + console.print( + f"[red]Error:[/red] variation [cyan]{variation}[/cyan] not found." + f" Available: {', '.join(variation_names)}" + ) + raise typer.Exit(1) + new_keys = [k.strip() for k in context_keys.split(",") if k.strip()] + targets: dict[str, Any] = dict(flag.get("targets") or {}) + existing = list(targets.get(variation, [])) + added = [k for k in new_keys if k not in existing] + existing.extend(added) + targets[variation] = existing + await client.patch_flag(key, {"targets": targets}) + console.print( + f"[green]✓[/green] Added {len(added)} key(s)" + f" to [bold cyan]{key}[/bold cyan] → [green]{variation}[/green]." + ) + + _run(_run_target) + + +@flags_app.command("untarget") +def flags_untarget( + key: str = typer.Argument(..., help="Flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation to remove context keys from" + ), + context_keys: str = typer.Option( + ..., "--keys", "-k", help="Comma-separated context keys to unpin" + ), +) -> None: + """Remove context keys from individual targeting. + + \b + shield flags untarget my-flag --variation on --keys user_123 + """ + + async def _run_untarget() -> None: + client = make_client() + flag = await client.get_flag(key) + remove_keys = {k.strip() for k in context_keys.split(",") if k.strip()} + targets: dict[str, Any] = dict(flag.get("targets") or {}) + existing = list(targets.get(variation, [])) + if not existing: + console.print( + f"[yellow]Warning:[/yellow] no targets for variation [cyan]{variation}[/cyan]." + ) + raise typer.Exit(1) + updated = [k for k in existing if k not in remove_keys] + if updated: + targets[variation] = updated + else: + targets.pop(variation, None) + await client.patch_flag(key, {"targets": targets}) + removed = len(existing) - len(updated) + console.print( + f"[green]✓[/green] Removed {removed} key(s)" + f" from [bold cyan]{key}[/bold cyan] → [cyan]{variation}[/cyan]." + ) + + _run(_run_untarget) + + +# --------------------------------------------------------------------------- +# Segments command group (shield segments ...) +# --------------------------------------------------------------------------- + +segments_app = typer.Typer( + name="segments", + help="Manage targeting segments.", + no_args_is_help=True, +) +cli.add_typer(segments_app, name="segments") +cli.add_typer(segments_app, name="seg") + + +def _print_segments_table(segments: list[dict[str, Any]]) -> None: + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False) + tbl.add_column("Key", style="bold cyan", no_wrap=True) + tbl.add_column("Name", style="white") + tbl.add_column("Included", style="green") + tbl.add_column("Excluded", style="red") + tbl.add_column("Rules", style="dim") + for s in segments: + included = s.get("included") or [] + excluded = s.get("excluded") or [] + rules = s.get("rules") or [] + tbl.add_row( + s.get("key", ""), + s.get("name", ""), + str(len(included)), + str(len(excluded)), + str(len(rules)), + ) + console.print(tbl) + + +@segments_app.command("list") +def segments_list() -> None: + """List all targeting segments.""" + + async def _run_segments_list() -> None: + segments = await make_client().list_segments() + if not segments: + console.print("[dim]No segments found.[/dim]") + return + _print_segments_table(segments) + console.print(f"[dim]{len(segments)} segment(s)[/dim]") + + _run(_run_segments_list) + + +@segments_app.command("get") +def segments_get(key: str = typer.Argument(..., help="Segment key")) -> None: + """Show details for a single segment.""" + + async def _run_segments_get() -> None: + seg = await make_client().get_segment(key) + console.print(f"[bold cyan]{seg['key']}[/bold cyan] [dim]{seg.get('name', '')}[/dim]") + included = seg.get("included") or [] + excluded = seg.get("excluded") or [] + rules = seg.get("rules") or [] + if included: + console.print( + f" Included ({len(included)}): [green]{', '.join(included[:10])}[/green]" + + (" …" if len(included) > 10 else "") + ) + if excluded: + console.print( + f" Excluded ({len(excluded)}): [red]{', '.join(excluded[:10])}[/red]" + + (" …" if len(excluded) > 10 else "") + ) + if rules: + console.print(f" Rules: [dim]{len(rules)} targeting rule(s)[/dim]") + if not included and not excluded and not rules: + console.print(" [dim](empty segment)[/dim]") + + _run(_run_segments_get) + + +@segments_app.command("create") +def segments_create( + key: str = typer.Argument(..., help="Unique segment key"), + name: str = typer.Option(..., "--name", "-n", help="Human-readable segment name"), + description: str = typer.Option("", "--description", "-d", help="Optional description"), +) -> None: + """Create a new targeting segment. + + \b + shield segments create beta_users --name "Beta Users" + """ + + async def _run_segments_create() -> None: + segment_data = { + "key": key, + "name": name, + "description": description, + "included": [], + "excluded": [], + "rules": [], + } + result = await make_client().create_segment(segment_data) + console.print(f"[green]✓[/green] Segment [bold cyan]{result['key']}[/bold cyan] created.") + + _run(_run_segments_create) + + +@segments_app.command("delete") +def segments_delete( + key: str = typer.Argument(..., help="Segment key"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Permanently delete a targeting segment.""" + if not yes: + typer.confirm(f"Delete segment '{key}'? This cannot be undone.", abort=True) + + async def _run_segments_delete() -> None: + result = await make_client().delete_segment(key) + console.print(f"[green]✓[/green] Segment [bold]{result['deleted']}[/bold] deleted.") + + _run(_run_segments_delete) + + +@segments_app.command("include") +def segments_include( + key: str = typer.Argument(..., help="Segment key"), + context_key: str = typer.Option( + ..., + "--context-key", + "-k", + help="Comma-separated context keys to add to the included list", + ), +) -> None: + """Add context keys to the segment's included list. + + \b + shield segments include beta_users --context-key user_123,user_456 + """ + + async def _run_segments_include() -> None: + new_keys = [k.strip() for k in context_key.split(",") if k.strip()] + seg = await make_client().get_segment(key) + included = list(seg.get("included") or []) + added = [k for k in new_keys if k not in included] + included.extend(added) + seg["included"] = included + await make_client().update_segment(key, seg) + console.print( + f"[green]✓[/green] Added {len(added)} key(s) to [bold cyan]{key}[/bold cyan] " + f"included list." + ) + + _run(_run_segments_include) + + +@segments_app.command("exclude") +def segments_exclude( + key: str = typer.Argument(..., help="Segment key"), + context_key: str = typer.Option( + ..., + "--context-key", + "-k", + help="Comma-separated context keys to add to the excluded list", + ), +) -> None: + """Add context keys to the segment's excluded list. + + \b + shield segments exclude beta_users --context-key user_789 + """ + + async def _run_segments_exclude() -> None: + new_keys = [k.strip() for k in context_key.split(",") if k.strip()] + seg = await make_client().get_segment(key) + excluded = list(seg.get("excluded") or []) + added = [k for k in new_keys if k not in excluded] + excluded.extend(added) + seg["excluded"] = excluded + await make_client().update_segment(key, seg) + console.print( + f"[green]✓[/green] Added {len(added)} key(s) to [bold cyan]{key}[/bold cyan] " + f"excluded list." + ) + + _run(_run_segments_exclude) + + +@segments_app.command("add-rule") +def segments_add_rule( + key: str = typer.Argument(..., help="Segment key"), + attribute: str = typer.Option( + ..., + "--attribute", + "-a", + help="Context attribute (e.g. plan, country)", + ), + operator: str = typer.Option( + "is", + "--operator", + "-o", + help="Operator (e.g. is, in, contains, in_segment)", + ), + values: str = typer.Option( + ..., + "--values", + "-V", + help="Comma-separated values to compare against", + ), + description: str = typer.Option("", "--description", "-d", help="Optional rule description"), + negate: bool = typer.Option(False, "--negate", help="Negate the clause result"), +) -> None: + """Add an attribute-based targeting rule to a segment. + + \b + Users matching ANY rule are included in the segment. + Multiple clauses within one rule are AND-ed together. + + \b + Examples: + shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise + shield segments add-rule beta_users --attribute country --operator is --values GB + shield segments add-rule beta_users --attribute email --operator ends_with \\ + --values @acme.com --description "Acme staff" + """ + + async def _run_add_rule() -> None: + import uuid as _uuid + + client = make_client() + seg = await client.get_segment(key) + rules = list(seg.get("rules") or []) + + # For segment operators the attribute defaults to "key" + attr = "key" if operator in ("in_segment", "not_in_segment") else attribute + raw_vals: list[Any] = [v.strip() for v in values.split(",") if v.strip()] + clause: dict[str, Any] = { + "attribute": attr, + "operator": operator, + "values": raw_vals, + "negate": negate, + } + new_rule: dict[str, Any] = { + "id": str(_uuid.uuid4()), + "clauses": [clause], + } + if description: + new_rule["description"] = description + rules.append(new_rule) + seg["rules"] = rules + await client.update_segment(key, seg) + + clause_summary = f"[cyan]{attr}[/cyan] [dim]{operator}[/dim] {values}" + console.print( + f"[green]✓[/green] Rule added to segment [bold cyan]{key}[/bold cyan]: {clause_summary}" + ) + console.print(f" [dim]id: {new_rule['id']}[/dim]") + + _run(_run_add_rule) + + +@segments_app.command("remove-rule") +def segments_remove_rule( + key: str = typer.Argument(..., help="Segment key"), + rule_id: str = typer.Option(..., "--rule-id", "-r", help="Rule ID to remove"), +) -> None: + """Remove a targeting rule from a segment by its ID. + + \b + shield segments remove-rule beta_users --rule-id + + Use 'shield segments get beta_users' to list rule IDs. + """ + + async def _run_remove_rule() -> None: + client = make_client() + seg = await client.get_segment(key) + rules = list(seg.get("rules") or []) + original_len = len(rules) + rules = [r for r in rules if r.get("id") != rule_id] + if len(rules) == original_len: + console.print( + f"[red]Error:[/red] no rule with id '{rule_id}' found on segment '{key}'." + ) + raise typer.Exit(1) + seg["rules"] = rules + await client.update_segment(key, seg) + console.print( + f"[green]✓[/green] Rule [dim]{rule_id}[/dim] removed from segment " + f"[bold cyan]{key}[/bold cyan]." + ) + + _run(_run_remove_rule) + + if __name__ == "__main__": cli() diff --git a/shield/core/backends/base.py b/shield/core/backends/base.py index 9b3db76..6bba237 100644 --- a/shield/core/backends/base.py +++ b/shield/core/backends/base.py @@ -167,6 +167,17 @@ async def subscribe_global_config(self) -> AsyncIterator[None]: # Unreachable — makes this a valid async generator return type. yield + async def get_registered_paths(self) -> set[str]: + """Return the set of all registered path keys for deduplication. + + Used by ``ShieldEngine.register_batch()`` to detect already-registered + routes without re-querying the full state list. The default + implementation derives the set from ``list_states()``; backends that + store routes under transformed keys (e.g. ``ShieldServerBackend`` which + adds a service prefix) should override this to return local/plain keys. + """ + return {s.path for s in await self.list_states()} + # ------------------------------------------------------------------ # Rate limit hit log — concrete default implementations # ------------------------------------------------------------------ @@ -263,6 +274,58 @@ async def delete_global_rate_limit_policy(self) -> None: _GLOBAL_RL_KEY = "__shield:global_rl__" await self.delete_state(_GLOBAL_RL_KEY) + # ------------------------------------------------------------------ + # Per-service rate limit policy persistence — concrete default + # implementations using the sentinel RouteState pattern. + # Stored as a sentinel with path ``__shield:svc_rl:{service}__``. + # No subclass changes required for existing backends. + # ------------------------------------------------------------------ + + async def get_service_rate_limit_policy(self, service: str) -> dict[str, Any] | None: + """Return the persisted per-service rate limit policy dict, or ``None``.""" + import json + + key = f"__shield:svc_rl:{service}__" + try: + state = await self.get_state(key) + return dict(json.loads(state.reason)) + except (KeyError, Exception): + return None + + async def set_service_rate_limit_policy( + self, service: str, policy_data: dict[str, Any] + ) -> None: + """Persist *policy_data* as the rate limit policy for *service*.""" + import json + + key = f"__shield:svc_rl:{service}__" + sentinel = RouteState( + path=key, + status=RouteStatus.ACTIVE, + reason=json.dumps(policy_data), + service=service, + ) + await self.set_state(key, sentinel) + + async def delete_service_rate_limit_policy(self, service: str) -> None: + """Remove the persisted rate limit policy for *service*.""" + key = f"__shield:svc_rl:{service}__" + await self.delete_state(key) + + async def get_all_service_rate_limit_policies(self) -> dict[str, dict[str, Any]]: + """Return all persisted per-service rate limit policies as ``{service: policy_data}``.""" + import json + + result: dict[str, dict[str, Any]] = {} + for state in await self.list_states(): + if state.path.startswith("__shield:svc_rl:") and state.path.endswith("__"): + svc = state.path[len("__shield:svc_rl:") : -2] + try: + result[svc] = dict(json.loads(state.reason)) + except Exception: + pass + return result + async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: """Stream rate limit policy changes as they occur. @@ -279,3 +342,72 @@ async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: f"{type(self).__name__} does not support rate limit policy pub/sub." ) yield # make this a valid async generator + + # ------------------------------------------------------------------ + # Feature flag storage — concrete in-memory default implementations + # + # All backends get basic in-memory flag/segment storage for free. + # FileBackend and RedisBackend can override for persistence. + # Storage is lazily initialised on first use so existing backends + # that do not call super().__init__() are not affected. + # ------------------------------------------------------------------ + + def _flag_store(self) -> dict[str, Any]: + """Lazy per-instance dict for flag objects.""" + if not hasattr(self, "_flag_store_dict"): + object.__setattr__(self, "_flag_store_dict", {}) + return self._flag_store_dict # type: ignore[attr-defined, no-any-return] + + def _segment_store(self) -> dict[str, Any]: + """Lazy per-instance dict for segment objects.""" + if not hasattr(self, "_segment_store_dict"): + object.__setattr__(self, "_segment_store_dict", {}) + return self._segment_store_dict # type: ignore[attr-defined, no-any-return] + + async def load_all_flags(self) -> list[Any]: + """Return all stored feature flags. + + Returns a list of ``FeatureFlag`` objects. The default + implementation uses an in-memory store. Override for persistent + backends. + """ + return list(self._flag_store().values()) + + async def save_flag(self, flag: Any) -> None: + """Persist *flag* (a ``FeatureFlag`` instance) by its key. + + Default implementation keeps flags in memory. Override for + persistent backends. + """ + self._flag_store()[flag.key] = flag + + async def delete_flag(self, flag_key: str) -> None: + """Remove the flag with *flag_key* from storage. + + No-op if the flag does not exist. + """ + self._flag_store().pop(flag_key, None) + + async def load_all_segments(self) -> list[Any]: + """Return all stored segments. + + Returns a list of ``Segment`` objects. The default + implementation uses an in-memory store. Override for persistent + backends. + """ + return list(self._segment_store().values()) + + async def save_segment(self, segment: Any) -> None: + """Persist *segment* (a ``Segment`` instance) by its key. + + Default implementation keeps segments in memory. Override for + persistent backends. + """ + self._segment_store()[segment.key] = segment + + async def delete_segment(self, segment_key: str) -> None: + """Remove the segment with *segment_key* from storage. + + No-op if the segment does not exist. + """ + self._segment_store().pop(segment_key, None) diff --git a/shield/core/backends/memory.py b/shield/core/backends/memory.py index 5502191..f1c354c 100644 --- a/shield/core/backends/memory.py +++ b/shield/core/backends/memory.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib from collections import defaultdict, deque from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any @@ -52,6 +53,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*. @@ -67,7 +70,7 @@ async def set_state(self, path: str, state: RouteState) -> None: """Persist *state* for *path* and notify any subscribers.""" self._states[path] = state for queue in self._subscribers: - await queue.put(state) + queue.put_nowait(state) async def delete_state(self, path: str) -> None: """Remove state for *path*. No-op if not registered.""" @@ -118,7 +121,8 @@ async def subscribe(self) -> AsyncIterator[RouteState]: state = await queue.get() yield state finally: - self._subscribers.remove(queue) + with contextlib.suppress(ValueError): + self._subscribers.remove(queue) async def write_rate_limit_hit(self, hit: RateLimitHit) -> None: """Append a rate limit hit record, evicting the oldest when the cap is reached.""" @@ -148,13 +152,32 @@ 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: + q.put_nowait(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: + q.put_nowait(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: + with contextlib.suppress(ValueError): + 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..8074acf --- /dev/null +++ b/shield/core/backends/server.py @@ -0,0 +1,794 @@ +"""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 _GLOBAL_KEY, ShieldBackend +from shield.core.models import AuditEntry, GlobalMaintenanceConfig, 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, or before _startup_done is True. Keyed by server-side + # path so duplicates (e.g. from ShieldRouter + SDK scan) are collapsed. + # Flushed to the server as one batch once _flush_pending() completes. + self._pending: dict[str, RouteState] = {} + + # False until _flush_pending() completes for the first time. While + # False, set_state() always queues to _pending instead of firing + # individual _push_state() tasks so that ALL startup registrations — + # from ShieldRouter, from SDK route scan, from any ordering of startup + # hooks — travel in exactly ONE HTTP round-trip at the end. Once True, + # real-time pushes are enabled for runtime state mutations (enable, + # disable, maintenance, etc.). + self._startup_done: bool = False + + # Queues signalled whenever the global-maintenance config changes + # (all-services global key OR the service-specific key). Used by + # subscribe_global_config() so the engine can invalidate its cache. + self._global_config_changed: list[asyncio.Queue[None]] = [] + + # 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]]] = [] + + # Queues for state-change subscribers (fed by _listen_sse on "state" events). + # Consumed by subscribe() so _run_route_state_listener bumps _schema_version, + # which invalidates the apply_shield_to_openapi cache on every SSE update. + self._state_subscribers: list[asyncio.Queue[RouteState]] = [] + + # Local feature flag / segment cache (populated by SSE flag events). + self._flag_cache: dict[str, Any] = {} # key → FeatureFlag raw dict + self._segment_cache: dict[str, Any] = {} # key → Segment raw dict + self._flag_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). + + Sets :attr:`_startup_done` to ``True`` after the flush (whether or not + the HTTP request succeeded) so that subsequent :meth:`set_state` calls + — from runtime mutations like ``engine.disable()`` — are pushed + immediately instead of being queued. + """ + try: + if not self._pending or self._client is None: + return + batch = list(self._pending.values()) + 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, + ) + finally: + # Mark startup complete so runtime set_state() calls push immediately. + self._startup_done = True + + # ------------------------------------------------------------------ + # 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, + ) + # Notify state subscribers so _run_route_state_listener + # bumps _schema_version, which invalidates the OpenAPI cache. + _svc_global_key = f"__shield:svc_global:{self._app_id}__" + if local_key not in (_GLOBAL_KEY, _svc_global_key): + for q in self._state_subscribers: + q.put_nowait(state) + # Notify engine to invalidate global-maintenance cache when + # the all-services global config OR this service's own + # maintenance config arrives via SSE. + else: + for gc_q in self._global_config_changed: + gc_q.put_nowait(None) + + 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 rl_q in self._rl_policy_subscribers: + rl_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 rl_q in self._rl_policy_subscribers: + rl_q.put_nowait(del_event) + logger.debug( + "ShieldServerBackend[%s]: RL policy deleted — %s", self._app_id, key + ) + + elif event_type == "flag_updated": + key = envelope.get("key", "") + flag_data = envelope.get("flag") + if key and flag_data is not None: + self._flag_cache[key] = flag_data + flag_event: dict[str, Any] = { + "type": "flag_updated", + "key": key, + "flag": flag_data, + } + for flag_q in self._flag_subscribers: + flag_q.put_nowait(flag_event) + logger.debug( + "ShieldServerBackend[%s]: flag cache updated — %s", + self._app_id, + key, + ) + + elif event_type == "flag_deleted": + key = envelope.get("key", "") + if key: + self._flag_cache.pop(key, None) + flag_del_event: dict[str, Any] = {"type": "flag_deleted", "key": key} + for flag_q in self._flag_subscribers: + flag_q.put_nowait(flag_del_event) + logger.debug( + "ShieldServerBackend[%s]: flag deleted — %s", self._app_id, key + ) + + elif event_type == "segment_updated": + key = envelope.get("key", "") + seg_data = envelope.get("segment") + if key and seg_data is not None: + self._segment_cache[key] = seg_data + seg_event: dict[str, Any] = { + "type": "segment_updated", + "key": key, + "segment": seg_data, + } + for flag_q in self._flag_subscribers: + flag_q.put_nowait(seg_event) + logger.debug( + "ShieldServerBackend[%s]: segment cache updated — %s", + self._app_id, + key, + ) + + elif event_type == "segment_deleted": + key = envelope.get("key", "") + if key: + self._segment_cache.pop(key, None) + seg_del_event: dict[str, Any] = {"type": "segment_deleted", "key": key} + for flag_q in self._flag_subscribers: + flag_q.put_nowait(seg_del_event) + logger.debug( + "ShieldServerBackend[%s]: segment 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 + for q in self._state_subscribers: + q.put_nowait(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 or not self._startup_done: + # Queue for batch flush: use server-side path as key so duplicate + # registrations (e.g. from both ShieldRouter and SDK scan) collapse + # into one entry — latest state wins. + self._pending[state.path] = 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]: + # Exclude sentinel keys used for global/service maintenance configs + # so they don't appear as regular routes in the dashboard or CLI. + # + # Normalize state.path to the local (app-side) cache key — e.g. + # "GET:/api/payments" — rather than the server-side namespaced path + # "payments-service:GET:/api/payments". Callers such as + # apply_shield_to_openapi look up states by the local path that + # matches FastAPI's route paths, so the paths must align. + result: list[RouteState] = [] + for local_key, state in self._cache.items(): + if local_key.startswith("__shield:"): + continue + if state.path != local_key: + state = state.model_copy(update={"path": local_key}) + result.append(state) + return result + + # ------------------------------------------------------------------ + # 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() — yields state changes received via the SSE connection + # ------------------------------------------------------------------ + + async def subscribe(self) -> AsyncIterator[RouteState]: + """Yield every route-state change pushed by the Shield Server via SSE. + + State changes arrive through the existing ``/api/sdk/events`` SSE + connection managed by :meth:`_listen_sse`. Each yielded + ``RouteState`` is a normal (non-sentinel) route update for this + service. + + The engine's ``_run_route_state_listener`` consumes this generator + and calls ``_bump_schema_version()`` on every yield so that + ``apply_shield_to_openapi``'s cache is invalidated and ``/docs`` + reflects live state changes made via the dashboard or CLI. + """ + q: asyncio.Queue[RouteState] = asyncio.Queue() + self._state_subscribers.append(q) + try: + while True: + yield await q.get() + finally: + with contextlib.suppress(ValueError): + self._state_subscribers.remove(q) + + # ------------------------------------------------------------------ + # Global maintenance — SSE-driven cache invalidation + # ------------------------------------------------------------------ + + async def subscribe_global_config(self) -> AsyncIterator[None]: + """Yield whenever the global or service-specific maintenance config changes. + + The signal arrives via the SDK's existing SSE connection: when + :meth:`_listen_sse` receives a state update for the global-config + sentinel key (``_GLOBAL_KEY``) or this service's own maintenance key, + it puts ``None`` into every queue registered here. The engine + consumes these signals to invalidate its in-process config cache. + """ + q: asyncio.Queue[None] = asyncio.Queue() + self._global_config_changed.append(q) + try: + while True: + await q.get() + yield None + finally: + try: + self._global_config_changed.remove(q) + except ValueError: + pass + + async def get_global_config(self) -> GlobalMaintenanceConfig: + """Return the effective global-maintenance config for this service. + + Checks (in order): + 1. All-services global maintenance (``_GLOBAL_KEY``) — if enabled, + return immediately (highest priority). + 2. Service-specific maintenance (``__shield:svc_global:__``) + — allows per-service emergency maintenance without touching all + other services. + + Both configs are read from the local SSE-driven cache so there is + no network hop per request. + """ + # 1. All-services global config + try: + state = self._cache.get(_GLOBAL_KEY) + if state is not None: + cfg = GlobalMaintenanceConfig.model_validate_json(state.reason) + if cfg.enabled: + return cfg + except Exception: # noqa: BLE001 + pass + # 2. Service-specific maintenance config + svc_key = f"__shield:svc_global:{self._app_id}__" + try: + state = self._cache.get(svc_key) + if state is not None: + return GlobalMaintenanceConfig.model_validate_json(state.reason) + except Exception: # noqa: BLE001 + pass + return GlobalMaintenanceConfig() + + # ------------------------------------------------------------------ + # Route deduplication helper + # ------------------------------------------------------------------ + + async def get_registered_paths(self) -> set[str]: + """Return local cache keys for route-deduplication in register_batch. + + Unlike the default implementation (which uses state ``.path`` fields + that include the service prefix), this returns the local cache keys — + i.e. plain paths like ``"GET:/api/payments"`` or ``"/api/payments"``. + This lets ``engine.register_batch()`` correctly detect routes that + are already known to this SDK instance, regardless of whether they + were synced from the server with or without a method prefix. + + The returned set also includes bare-path variants (method prefix + stripped) so that routes registered by ``ShieldRouter`` as + ``"GET:/api/payments"`` are matched against SDK-discovered paths of + ``"/api/payments"``. + """ + # Exclude sentinel keys (global config, service maintenance) so they + # are never treated as real routes in register_batch dedup checks. + keys = {k for k in self._cache.keys() if not k.startswith("__shield:")} + bare: set[str] = set() + _methods = ("GET:", "POST:", "PUT:", "PATCH:", "DELETE:", "HEAD:", "OPTIONS:") + for key in keys: + for m in _methods: + if key.startswith(m): + bare.add(key[len(m) :]) + break + return keys | bare + + # ------------------------------------------------------------------ + # 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: + with contextlib.suppress(ValueError): + self._rl_policy_subscribers.remove(queue) + + async def subscribe_flag_changes(self) -> AsyncIterator[dict[str, Any]]: + """Yield feature flag / segment change events pushed via the SSE connection. + + Each yielded dict has one of these shapes:: + + {"type": "flag_updated", "key": "my-flag", "flag": {...}} + {"type": "flag_deleted", "key": "my-flag"} + {"type": "segment_updated", "key": "my-seg", "segment": {...}} + {"type": "segment_deleted", "key": "my-seg"} + """ + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._flag_subscribers.append(queue) + try: + while True: + yield await queue.get() + finally: + with contextlib.suppress(ValueError): + self._flag_subscribers.remove(queue) + + # ------------------------------------------------------------------ + # Feature flag storage — returns locally cached data fetched via SSE + # ------------------------------------------------------------------ + + async def load_all_flags(self) -> list[Any]: + """Return all feature flags cached from the Shield Server.""" + return list(self._flag_cache.values()) + + async def load_all_segments(self) -> list[Any]: + """Return all segments cached from the Shield Server.""" + return list(self._segment_cache.values()) diff --git a/shield/core/engine.py b/shield/core/engine.py index e371dc8..9a7ebcc 100644 --- a/shield/core/engine.py +++ b/shield/core/engine.py @@ -172,6 +172,41 @@ def set_global_exempt_paths(self, paths: list[str]) -> GlobalMaintenanceConfig: """Sync version of :meth:`ShieldEngine.set_global_exempt_paths`.""" return self._run(self._engine.set_global_exempt_paths(paths)) + def get_service_maintenance(self, service: str) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.get_service_maintenance`.""" + return self._run(self._engine.get_service_maintenance(service)) + + def enable_service_maintenance( + self, + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + actor: str = "system", + platform: str = "system", + ) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.enable_service_maintenance`.""" + return self._run( + self._engine.enable_service_maintenance( + service=service, + reason=reason, + exempt_paths=exempt_paths, + include_force_active=include_force_active, + actor=actor, + platform=platform, + ) + ) + + def disable_service_maintenance( + self, service: str, actor: str = "system", platform: str = "system" + ) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.disable_service_maintenance`.""" + return self._run( + self._engine.disable_service_maintenance( + service=service, actor=actor, platform=platform + ) + ) + # ------------------------------------------------------------------ # Rate limiting # ------------------------------------------------------------------ @@ -268,6 +303,62 @@ def disable_global_rate_limit(self, *, actor: str = "system", platform: str = "s """Sync version of :meth:`ShieldEngine.disable_global_rate_limit`.""" self._run(self._engine.disable_global_rate_limit(actor=actor, platform=platform)) + def get_service_rate_limit(self, service: str) -> Any: + """Sync version of :meth:`ShieldEngine.get_service_rate_limit`.""" + return self._run(self._engine.get_service_rate_limit(service)) + + def set_service_rate_limit( + self, + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "system", + ) -> Any: + """Sync version of :meth:`ShieldEngine.set_service_rate_limit`.""" + return self._run( + self._engine.set_service_rate_limit( + service, + limit, + algorithm=algorithm, + key_strategy=key_strategy, + on_missing_key=on_missing_key, + burst=burst, + exempt_routes=exempt_routes, + actor=actor, + platform=platform, + ) + ) + + def delete_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.delete_service_rate_limit`.""" + self._run(self._engine.delete_service_rate_limit(service, actor=actor, platform=platform)) + + def reset_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.reset_service_rate_limit`.""" + self._run(self._engine.reset_service_rate_limit(service, actor=actor, platform=platform)) + + def enable_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.enable_service_rate_limit`.""" + self._run(self._engine.enable_service_rate_limit(service, actor=actor, platform=platform)) + + def disable_service_rate_limit( + self, service: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.disable_service_rate_limit`.""" + self._run(self._engine.disable_service_rate_limit(service, actor=actor, platform=platform)) + # ------------------------------------------------------------------ # Read-only queries # ------------------------------------------------------------------ @@ -284,6 +375,34 @@ def get_audit_log(self, path: str | None = None, limit: int = 100) -> list[Audit """Sync version of :meth:`ShieldEngine.get_audit_log`.""" return self._run(self._engine.get_audit_log(path=path, limit=limit)) + # ------------------------------------------------------------------ + # Feature flags + # ------------------------------------------------------------------ + + @property + def flag_client(self) -> Any: + """Return the synchronous flag client, or ``None`` if flags are not active. + + Call ``engine.use_openfeature()`` first to activate the flag system. + + Since OpenFeature evaluation is CPU-bound, this client does **not** + require a thread bridge — all methods are safe to call directly from + a ``def`` handler running in an anyio worker thread. + + Example:: + + @router.get("/checkout") + def checkout(request: Request): + enabled = engine.sync.flag_client.get_boolean_value( + "new_checkout", False, {"targeting_key": request.state.user_id} + ) + return checkout_v2() if enabled else checkout_v1() + """ + fc = self._engine._flag_client + if fc is None: + return None + return fc.sync + class ShieldEngine: """Central orchestrator — all route lifecycle logic flows through here. @@ -342,8 +461,13 @@ def __init__( self._rate_limiter: Any = None # ShieldRateLimiter | None self._rate_limit_policies: dict[str, Any] = {} # "METHOD:/path" → RateLimitPolicy self._global_rate_limit_policy: Any = None # GlobalRateLimitPolicy | None + self._service_rate_limit_policies: dict[str, Any] = {} # service → GlobalRateLimitPolicy # Sync proxy — created once, reused on every engine.sync access. self.sync: _SyncProxy = _SyncProxy(self) + # Feature flags — lazily set by use_openfeature(). + self._flag_provider: Any = None # ShieldOpenFeatureProvider | None + self._flag_client: Any = None # ShieldFeatureClient | None + self._flag_scheduler: Any = None # FlagScheduler | None (set by use_openfeature) # ------------------------------------------------------------------ # Async context manager — calls backend lifecycle hooks @@ -407,6 +531,19 @@ async def start(self) -> None: self._run_rl_policy_listener(), name="shield-rl-policy-listener", ) + if self._flag_provider is not None: + # The OpenFeature SDK calls initialize() synchronously at + # set_provider() time. For async overrides the SDK silently + # discards the coroutine; engine.start() detects and awaits it. + # For sync initialize (including the base-class no-op) the SDK + # already ran it, so we skip the redundant call and go straight + # to warming the async backend cache. + if asyncio.iscoroutinefunction(type(self._flag_provider).initialize): + await self._flag_provider.initialize() + else: + await self._flag_provider._load_all() + if self._flag_scheduler is not None: + await self._flag_scheduler.start() async def stop(self) -> None: """Cancel background listener tasks and wait for them to finish. @@ -429,6 +566,214 @@ async def stop(self) -> None: with contextlib.suppress(asyncio.CancelledError): await self._rl_policy_listener_task self._rl_policy_listener_task = None + if self._flag_scheduler is not None: + await self._flag_scheduler.stop() + if self._flag_provider is not None: + if asyncio.iscoroutinefunction(type(self._flag_provider).shutdown): + await self._flag_provider.shutdown() + else: + self._flag_provider.shutdown() + + # ------------------------------------------------------------------ + # Feature flags — OpenFeature wiring + # ------------------------------------------------------------------ + + def use_openfeature( + self, + provider: Any = None, + hooks: list[Any] | None = None, + domain: str = "shield", + ) -> Any: + """Activate the feature flag system backed by this engine's backend. + + Parameters + ---------- + provider: + An OpenFeature-compliant provider to use. Defaults to + ``ShieldOpenFeatureProvider(self.backend)`` — the built-in + provider backed by the same backend as the engine. + hooks: + Additional OpenFeature hooks to register globally. Default + hooks (``LoggingHook``) are always added. + domain: + The OpenFeature domain name for the client. Defaults to + ``"shield"``. + + Returns + ------- + ShieldFeatureClient + The feature client ready for flag evaluations. + + Raises + ------ + ImportError + When ``api-shield[flags]`` is not installed. + """ + from shield.core.feature_flags._guard import _require_flags + + _require_flags() + + import openfeature.api as of_api + from openfeature.hook import Hook + + from shield.core.feature_flags.client import ShieldFeatureClient + from shield.core.feature_flags.hooks import LoggingHook + from shield.core.feature_flags.provider import ShieldOpenFeatureProvider + + if provider is None: + provider = ShieldOpenFeatureProvider(self.backend) + + self._flag_provider = provider + + # Register the provider under the given domain (OpenFeature >=0.8 API). + try: + of_api.set_provider(provider, domain=domain) + except TypeError: + # Older openfeature-sdk versions without domain support. + of_api.set_provider(provider) + + from shield.core.feature_flags.hooks import MetricsHook + + metrics_hook = MetricsHook() + + # Build the default hook list and merge with any user-supplied hooks. + default_hooks: list[Hook] = [LoggingHook(), metrics_hook] + all_hooks = default_hooks + (hooks or []) + of_api.add_hooks(all_hooks) + + # Create and cache the client. + self._flag_client = ShieldFeatureClient(domain=domain) + + # Create the scheduler (start() is called later in engine.start()). + from shield.core.feature_flags.scheduler import FlagScheduler + + self._flag_scheduler = FlagScheduler(self) + + return self._flag_client + + @property + def flag_client(self) -> Any: + """Return the active ``ShieldFeatureClient``, or ``None`` if not configured. + + Call ``engine.use_openfeature()`` first to activate the flag system. + """ + return self._flag_client + + @property + def flag_scheduler(self) -> Any: + """Return the active ``FlagScheduler``, or ``None`` if not configured.""" + return self._flag_scheduler + + # ------------------------------------------------------------------ + # Feature flag CRUD — single chokepoint for flag + segment operations + # ------------------------------------------------------------------ + + async def list_flags(self) -> list[Any]: + """Return all feature flags from the provider cache (or backend).""" + if self._flag_provider is not None: + return list(self._flag_provider._flags.values()) + return await self.backend.load_all_flags() + + async def get_flag(self, key: str) -> Any: + """Return a single ``FeatureFlag`` by *key*, or ``None`` if not found.""" + if self._flag_provider is not None: + return self._flag_provider._flags.get(key) + flags = await self.backend.load_all_flags() + return next((f for f in flags if f.key == key), None) + + async def save_flag( + self, + flag: Any, + actor: str = "system", + platform: str = "", + action: str | None = None, + audit: bool = True, + ) -> None: + """Persist *flag* to the backend and update the provider cache.""" + existing = await self.get_flag(flag.key) + default_action = "flag_created" if existing is None else "flag_updated" + await self.backend.save_flag(flag) + if self._flag_provider is not None: + self._flag_provider.upsert_flag(flag) + if audit: + await self._audit_rl( + path=f"flag:{flag.key}", + action=action or default_action, + actor=actor, + platform=platform, + ) + + async def delete_flag( + self, + key: str, + actor: str = "system", + platform: str = "", + audit: bool = True, + ) -> None: + """Delete a flag by *key* from the backend and provider cache.""" + await self.backend.delete_flag(key) + if self._flag_provider is not None: + self._flag_provider.delete_flag(key) + if audit: + await self._audit_rl( + path=f"flag:{key}", + action="flag_deleted", + actor=actor, + platform=platform, + ) + + async def list_segments(self) -> list[Any]: + """Return all segments from the provider cache (or backend).""" + if self._flag_provider is not None: + return list(self._flag_provider._segments.values()) + return await self.backend.load_all_segments() + + async def get_segment(self, key: str) -> Any: + """Return a single ``Segment`` by *key*, or ``None`` if not found.""" + if self._flag_provider is not None: + return self._flag_provider._segments.get(key) + segments = await self.backend.load_all_segments() + return next((s for s in segments if s.key == key), None) + + async def save_segment( + self, + segment: Any, + actor: str = "system", + platform: str = "", + audit: bool = True, + ) -> None: + """Persist *segment* to the backend and update the provider cache.""" + existing = await self.get_segment(segment.key) + action = "segment_created" if existing is None else "segment_updated" + await self.backend.save_segment(segment) + if self._flag_provider is not None: + self._flag_provider.upsert_segment(segment) + if audit: + await self._audit_rl( + path=f"segment:{segment.key}", + action=action, + actor=actor, + platform=platform, + ) + + async def delete_segment( + self, + key: str, + actor: str = "system", + platform: str = "", + audit: bool = True, + ) -> None: + """Delete a segment by *key* from the backend and provider cache.""" + await self.backend.delete_segment(key) + if self._flag_provider is not None: + self._flag_provider.delete_segment(key) + if audit: + await self._audit_rl( + path=f"segment:{key}", + action="segment_deleted", + actor=actor, + platform=platform, + ) async def _run_global_config_listener(self) -> None: """Background coroutine: invalidate the global config cache on remote changes. @@ -590,8 +935,10 @@ async def check( if state is None: return # no state registered → effectively ACTIVE + service = state.service if state is not None else None + if state.status == RouteStatus.ACTIVE: - return await self._run_rate_limit_check(path, method or "", context) + return await self._run_rate_limit_check(path, method or "", context, service=service) if state.status == RouteStatus.MAINTENANCE: retry_after = state.window.end if state.window else None @@ -612,15 +959,19 @@ async def check( if state.status == RouteStatus.DEPRECATED: # Deprecated routes still serve requests — headers injected by middleware. # Rate limit check still runs for deprecated routes. - return await self._run_rate_limit_check(path, method or "", context) + return await self._run_rate_limit_check(path, method or "", context, service=service) # Rate limiting runs after all lifecycle checks (maintenance / disabled # routes short-circuit before touching any counters). Within the rate # limit check, the global limit is evaluated first. - await self._run_rate_limit_check(path, method or "", context) + await self._run_rate_limit_check(path, method or "", context, service=service) async def _run_rate_limit_check( - self, path: str, method: str, context: dict[str, Any] | None + self, + path: str, + method: str, + context: dict[str, Any] | None, + service: str | None = None, ) -> Any: """Run the rate limit check for *path*/*method* if a policy is registered. @@ -641,6 +992,15 @@ async def _run_rate_limit_check( if not self._is_globally_exempt(path, method, grl.exempt_routes): await self._run_global_rate_limit_check(path, method, request, grl) + # Per-service rate limit — runs after the all-services global limit + # but before the per-route limit. Only applies when the route belongs + # to a named service and a policy has been configured for that service. + if service: + srl = self._service_rate_limit_policies.get(service) + if srl is not None and srl.enabled: + if not self._is_globally_exempt(path, method, srl.exempt_routes): + await self._run_service_rate_limit_check(path, method, request, srl, service) + # Per-route check — only reached when the global limit passed (or the # route is exempt from the global limit, or no global limit is set). route_result: Any = None @@ -669,6 +1029,52 @@ async def _run_rate_limit_check( return route_result + async def _run_service_rate_limit_check( + self, + path: str, + method: str, + request: Any, + srl_policy: Any, + service: str, + ) -> None: + """Check the per-service rate limit for *path*/*method*. + + Uses ``__svc_rl:{service}__`` as the virtual path so all routes of + the same service share one counter namespace. Raises + ``RateLimitExceededException`` when the limit is exceeded. + """ + await self._ensure_rate_limiter() + + from shield.core.rate_limit.models import RateLimitPolicy + + virtual_path = f"__svc_rl:{service}__" + srl_as_policy = RateLimitPolicy( + path=virtual_path, + method="ALL", + limit=srl_policy.limit, + algorithm=srl_policy.algorithm, + key_strategy=srl_policy.key_strategy, + on_missing_key=srl_policy.on_missing_key, + burst=srl_policy.burst, + ) + + result = await self._rate_limiter.check( + path=virtual_path, + method="ALL", + request=request, + policy=srl_as_policy, + ) + + if not result.allowed: + await self._record_rate_limit_hit(path, method or "ALL", srl_as_policy, result) + raise RateLimitExceededException( + limit=result.limit, + retry_after_seconds=result.retry_after_seconds, + reset_at=result.reset_at, + remaining=0, + key=result.key, + ) + def _is_globally_exempt(self, path: str, method: str, exempt_routes: list[str]) -> bool: """Return ``True`` if *path*/*method* is in the global exempt list. @@ -841,9 +1247,12 @@ async def register_batch(self, routes: list[tuple[str, dict[str, Any]]]) -> None return # One backend call to discover every already-persisted route. + # Use get_registered_paths() instead of list_states() so that + # backends that store routes under transformed keys (e.g. + # ShieldServerBackend which adds a service prefix) correctly + # compare against plain local paths. try: - existing = await self.backend.list_states() - existing_keys: set[str] = {s.path for s in existing} + existing_keys: set[str] = await self.backend.get_registered_paths() except Exception: logger.exception( "shield: register_batch — failed to list existing states, " @@ -1194,6 +1603,90 @@ async def set_global_exempt_paths( self._bump_schema_version() return updated + # ------------------------------------------------------------------ + # Per-service global maintenance + # ------------------------------------------------------------------ + + @staticmethod + def _service_global_key(service: str) -> str: + return f"__shield:svc_global:{service}__" + + async def get_service_maintenance(self, service: str) -> GlobalMaintenanceConfig: + """Return the current per-service maintenance config for *service*.""" + key = self._service_global_key(service) + try: + state = await self.backend.get_state(key) + return GlobalMaintenanceConfig.model_validate_json(state.reason) + except (KeyError, Exception): + return GlobalMaintenanceConfig() + + async def enable_service_maintenance( + self, + service: str, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + actor: str = "system", + platform: str = "system", + ) -> GlobalMaintenanceConfig: + """Enable maintenance mode for all routes belonging to *service*. + + Stores a per-service sentinel in the backend (similar to the + all-services global maintenance sentinel). SDK clients with the + matching ``app_id`` pick this up via SSE and apply it as an + effective global maintenance for their service only. + """ + key = self._service_global_key(service) + cfg = GlobalMaintenanceConfig( + enabled=True, + reason=reason, + exempt_paths=exempt_paths or [], + include_force_active=include_force_active, + ) + sentinel = RouteState( + path=key, + status=RouteStatus.ACTIVE, + reason=cfg.model_dump_json(), + service=service, + ) + await self.backend.set_state(key, sentinel) + await self._audit( + path=key, + action="service_maintenance_on", + actor=actor, + reason=reason, + platform=platform, + previous_status=RouteStatus.ACTIVE, + new_status=RouteStatus.MAINTENANCE, + ) + return cfg + + async def disable_service_maintenance( + self, + service: str, + actor: str = "system", + platform: str = "system", + ) -> GlobalMaintenanceConfig: + """Disable per-service maintenance, restoring normal per-route state.""" + key = self._service_global_key(service) + cfg = GlobalMaintenanceConfig(enabled=False) + sentinel = RouteState( + path=key, + status=RouteStatus.ACTIVE, + reason=cfg.model_dump_json(), + service=service, + ) + await self.backend.set_state(key, sentinel) + await self._audit( + path=key, + action="service_maintenance_off", + actor=actor, + platform=platform, + previous_status=RouteStatus.MAINTENANCE, + new_status=RouteStatus.ACTIVE, + ) + return cfg + # ------------------------------------------------------------------ # Webhook management # ------------------------------------------------------------------ @@ -1410,6 +1903,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 +1943,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, @@ -1522,6 +2054,7 @@ async def restore_rate_limit_policies(self) -> None: # Also restore the global rate limit policy if one was persisted. await self._restore_global_rate_limit_policy() + await self._restore_service_rate_limit_policies() async def _restore_global_rate_limit_policy(self) -> None: """Load the persisted global rate limit policy from the backend.""" @@ -1564,6 +2097,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, @@ -1691,6 +2225,182 @@ async def disable_global_rate_limit( platform=platform, ) + async def _restore_service_rate_limit_policies(self) -> None: + """Load all persisted per-service rate limit policies from the backend.""" + try: + all_policies = await self.backend.get_all_service_rate_limit_policies() + except Exception: + logger.exception("shield: failed to restore service rate limit policies from backend") + return + + from shield.core.rate_limit.models import GlobalRateLimitPolicy + + for service, policy_data in all_policies.items(): + try: + self._service_rate_limit_policies[service] = GlobalRateLimitPolicy.model_validate( + policy_data + ) + logger.info("shield: restored service rate limit policy for %r", service) + except Exception: + logger.exception( + "shield: failed to parse service rate limit policy for %r", service + ) + + # ------------------------------------------------------------------ + # Per-service rate limit CRUD + # ------------------------------------------------------------------ + + @staticmethod + def _service_rl_key(service: str) -> str: + return f"__shield:svc_rl:{service}__" + + async def get_service_rate_limit(self, service: str) -> Any: + """Return the current ``GlobalRateLimitPolicy`` for *service*, or ``None``.""" + return self._service_rate_limit_policies.get(service) + + async def set_service_rate_limit( + self, + service: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "system", + ) -> Any: + """Set or update the per-service rate limit policy for *service*. + + Applies to all routes of *service* combined. Persisted so the + policy survives restarts. Returns the ``GlobalRateLimitPolicy``. + """ + self._validate_limit_string(limit) + from shield.core.rate_limit.models import ( + GlobalRateLimitPolicy, + OnMissingKey, + RateLimitAlgorithm, + RateLimitKeyStrategy, + ) + + algo = RateLimitAlgorithm(algorithm) if algorithm else RateLimitAlgorithm.FIXED_WINDOW + key_strat = RateLimitKeyStrategy(key_strategy) if key_strategy else RateLimitKeyStrategy.IP + omk = OnMissingKey(on_missing_key) if on_missing_key else None + + is_update = service in self._service_rate_limit_policies + policy = GlobalRateLimitPolicy( + limit=limit, + algorithm=algo, + key_strategy=key_strat, + on_missing_key=omk, + burst=burst, + exempt_routes=exempt_routes or [], + enabled=True, + ) + self._service_rate_limit_policies[service] = policy + await self.backend.set_service_rate_limit_policy(service, policy.model_dump(mode="json")) + logger.info( + "shield: service rate limit policy %s for %r (%s) by %s", + "updated" if is_update else "set", + service, + limit, + actor, + ) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_updated" if is_update else "svc_rl_set", + actor=actor, + reason=f"{limit} · {algo} · {key_strat}", + platform=platform, + ) + return policy + + async def delete_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Remove the per-service rate limit policy for *service*.""" + self._service_rate_limit_policies.pop(service, None) + await self.backend.delete_service_rate_limit_policy(service) + logger.info("shield: service rate limit policy deleted for %r by %s", service, actor) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_deleted", + actor=actor, + platform=platform, + ) + + async def reset_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Reset the in-process rate limit counters for *service*. + + Clears the ``__svc_rl:{service}__`` counter namespace. The policy + itself is not removed. + """ + if self._rate_limiter is None: + return + virtual_path = f"__svc_rl:{service}__" + await self._rate_limiter.reset(path=virtual_path, method="ALL") + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_reset", + actor=actor, + platform=platform, + ) + + async def enable_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Re-enable a paused per-service rate limit policy.""" + policy = self._service_rate_limit_policies.get(service) + if policy is None or policy.enabled: + return + self._service_rate_limit_policies[service] = policy.model_copy(update={"enabled": True}) + await self.backend.set_service_rate_limit_policy( + service, self._service_rate_limit_policies[service].model_dump(mode="json") + ) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_enabled", + actor=actor, + platform=platform, + ) + + async def disable_service_rate_limit( + self, + service: str, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Pause (disable) a per-service rate limit policy without removing it.""" + policy = self._service_rate_limit_policies.get(service) + if policy is None or not policy.enabled: + return + self._service_rate_limit_policies[service] = policy.model_copy(update={"enabled": False}) + await self.backend.set_service_rate_limit_policy( + service, self._service_rate_limit_policies[service].model_dump(mode="json") + ) + await self._audit_rl( + path=self._service_rl_key(service), + action="svc_rl_disabled", + actor=actor, + platform=platform, + ) + # ------------------------------------------------------------------ # Read methods # ------------------------------------------------------------------ @@ -1744,10 +2454,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/feature_flags/__init__.py b/shield/core/feature_flags/__init__.py new file mode 100644 index 0000000..62789c1 --- /dev/null +++ b/shield/core/feature_flags/__init__.py @@ -0,0 +1,153 @@ +"""shield.core.feature_flags — OpenFeature-compliant feature flag system. + +This package requires the [flags] optional extra:: + + pip install api-shield[flags] + +Importing from this package when the extra is not installed raises an +``ImportError`` with clear installation instructions. + +All public symbols are re-exported under Shield-namespaced names. +``openfeature`` never appears in user-facing imports. + +Usage +----- +:: + + from shield.core.feature_flags import ( + EvaluationContext, + ShieldFeatureClient, + EvaluationReason, + ResolutionDetails, + ) + + ctx = EvaluationContext(key=user_id, attributes={"plan": "pro"}) + value = await flag_client.get_boolean_value("new_checkout", False, ctx) + +Custom provider (implements OpenFeature's AbstractProvider):: + + from shield.core.feature_flags import ShieldFlagProvider + + class MyProvider(ShieldFlagProvider): + ... + +Custom hook (implements OpenFeature's Hook interface):: + + from shield.core.feature_flags import ShieldHook +""" + +from __future__ import annotations + +# ── Guard: raise early with a helpful message if openfeature not installed ── +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +# ── OpenFeature ABC re-exports (Shield-namespaced) ────────────────────────── +# These are the extension points for users who want custom providers/hooks. +from openfeature.hook import Hook as ShieldHook +from openfeature.provider import AbstractProvider as ShieldFlagProvider + +# ── Client and provider re-exports ────────────────────────────────────────── +# Imported lazily here so the module graph stays clean. +# client.py and provider.py each call _require_flags() themselves. +from shield.core.feature_flags.client import ShieldFeatureClient as ShieldFeatureClient + +# ── Hook re-exports ───────────────────────────────────────────────────────── +from shield.core.feature_flags.hooks import ( + AuditHook as AuditHook, +) +from shield.core.feature_flags.hooks import ( + LoggingHook as LoggingHook, +) +from shield.core.feature_flags.hooks import ( + MetricsHook as MetricsHook, +) +from shield.core.feature_flags.hooks import ( + OpenTelemetryHook as OpenTelemetryHook, +) + +# ── Shield-native model re-exports ────────────────────────────────────────── +from shield.core.feature_flags.models import ( + EvaluationContext as EvaluationContext, +) +from shield.core.feature_flags.models import ( + EvaluationReason as EvaluationReason, +) +from shield.core.feature_flags.models import ( + FeatureFlag as FeatureFlag, +) +from shield.core.feature_flags.models import ( + FlagStatus as FlagStatus, +) +from shield.core.feature_flags.models import ( + FlagType as FlagType, +) +from shield.core.feature_flags.models import ( + FlagVariation as FlagVariation, +) +from shield.core.feature_flags.models import ( + Operator as Operator, +) +from shield.core.feature_flags.models import ( + Prerequisite as Prerequisite, +) +from shield.core.feature_flags.models import ( + ResolutionDetails as ResolutionDetails, +) +from shield.core.feature_flags.models import ( + RolloutVariation as RolloutVariation, +) +from shield.core.feature_flags.models import ( + RuleClause as RuleClause, +) +from shield.core.feature_flags.models import ( + ScheduledChange as ScheduledChange, +) +from shield.core.feature_flags.models import ( + ScheduledChangeAction as ScheduledChangeAction, +) +from shield.core.feature_flags.models import ( + Segment as Segment, +) +from shield.core.feature_flags.models import ( + SegmentRule as SegmentRule, +) +from shield.core.feature_flags.models import ( + TargetingRule as TargetingRule, +) +from shield.core.feature_flags.provider import ( + ShieldOpenFeatureProvider as ShieldOpenFeatureProvider, +) + +__all__ = [ + # Extension points + "ShieldFlagProvider", + "ShieldHook", + # Models + "EvaluationContext", + "EvaluationReason", + "FeatureFlag", + "FlagStatus", + "FlagType", + "FlagVariation", + "Operator", + "Prerequisite", + "ResolutionDetails", + "RolloutVariation", + "RuleClause", + "ScheduledChange", + "ScheduledChangeAction", + "Segment", + "SegmentRule", + "TargetingRule", + # Client + "ShieldFeatureClient", + # Provider + "ShieldOpenFeatureProvider", + # Hooks + "AuditHook", + "LoggingHook", + "MetricsHook", + "OpenTelemetryHook", +] diff --git a/shield/core/feature_flags/_context.py b/shield/core/feature_flags/_context.py new file mode 100644 index 0000000..88b7671 --- /dev/null +++ b/shield/core/feature_flags/_context.py @@ -0,0 +1,65 @@ +"""Context conversion helpers between Shield and OpenFeature types. + +Converts ``shield.core.feature_flags.models.EvaluationContext`` → +``openfeature.evaluation_context.EvaluationContext`` for provider dispatch, +and back again for the native provider's evaluator calls. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from shield.core.feature_flags.models import EvaluationContext as ShieldContext + + +def to_of_context(ctx: ShieldContext | None) -> object | None: + """Convert a Shield ``EvaluationContext`` to an OpenFeature one. + + Returns ``None`` when *ctx* is ``None`` (OpenFeature accepts ``None`` + to mean "use global context"). + + Also accepts plain ``dict`` for convenience in sync callers — the + ``targeting_key`` entry is mapped to the OpenFeature targeting key. + """ + if ctx is None: + return None + + from openfeature.evaluation_context import EvaluationContext as OFContext + + if isinstance(ctx, dict): + d = dict(ctx) + targeting_key = d.pop("targeting_key", "anonymous") + return OFContext(targeting_key=targeting_key, attributes=d) + + attrs = ctx.all_attributes() + # targeting_key is the OpenFeature equivalent of our ctx.key + targeting_key = attrs.pop("key", ctx.key) + return OFContext(targeting_key=targeting_key, attributes=attrs) + + +def from_of_context(of_ctx: object | None) -> ShieldContext: + """Convert an OpenFeature ``EvaluationContext`` to a Shield one. + + Used inside ``ShieldOpenFeatureProvider`` when the OpenFeature SDK + dispatches a resolution call so that ``FlagEvaluator`` receives the + right type. + """ + from shield.core.feature_flags.models import EvaluationContext as ShieldContext + + if of_ctx is None: + return ShieldContext(key="anonymous") + + # OpenFeature EvaluationContext has targeting_key + attributes + targeting_key = getattr(of_ctx, "targeting_key", None) or "anonymous" + attributes: dict[str, Any] = getattr(of_ctx, "attributes", {}) or {} + + return ShieldContext( + key=targeting_key, + kind=attributes.pop("kind", "user"), + email=attributes.pop("email", None), + ip=attributes.pop("ip", None), + country=attributes.pop("country", None), + app_version=attributes.pop("app_version", None), + attributes=attributes, + ) diff --git a/shield/core/feature_flags/_guard.py b/shield/core/feature_flags/_guard.py new file mode 100644 index 0000000..616c157 --- /dev/null +++ b/shield/core/feature_flags/_guard.py @@ -0,0 +1,26 @@ +"""Import guard for the feature flags optional dependency. + +Call ``_require_flags()`` at the top of any module that needs +``openfeature`` before attempting to import it. This produces a clear, +actionable error message instead of a bare ``ModuleNotFoundError``. + +``shield/core/feature_flags/models.py`` and ``evaluator.py`` are pure +Pydantic/stdlib and do **not** call this guard — they are importable +regardless of whether the [flags] extra is installed. Only the public +``shield.core.feature_flags`` namespace (``__init__.py``) and the +provider/client modules call this guard. +""" + +from __future__ import annotations + + +def _require_flags() -> None: + """Raise ``ImportError`` with install instructions if openfeature is missing.""" + try: + import openfeature # noqa: F401 + except ImportError: + raise ImportError( + "Feature flags require the [flags] extra.\n" + "Install with: pip install api-shield[flags]\n" + "Or: uv pip install 'api-shield[flags]'" + ) from None diff --git a/shield/core/feature_flags/client.py b/shield/core/feature_flags/client.py new file mode 100644 index 0000000..9918578 --- /dev/null +++ b/shield/core/feature_flags/client.py @@ -0,0 +1,171 @@ +"""ShieldFeatureClient — OpenFeature-backed flag evaluation API. + +Phase 2 implementation. Stub present so the package imports cleanly. +""" + +from __future__ import annotations + +from typing import Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + + +class _SyncShieldFeatureClient: + """Synchronous façade over :class:`ShieldFeatureClient`. + + Access via ``engine.sync.flag_client`` from sync route handlers. + FastAPI runs ``def`` handlers in anyio worker threads, which is exactly + the context this class is designed for. + + Because OpenFeature evaluation is CPU-bound (pure Python, no I/O), all + methods call the underlying OpenFeature client directly — no thread + bridge or event-loop interaction needed. + + Examples + -------- + :: + + @router.get("/checkout") + def checkout(request: Request): + enabled = engine.sync.flag_client.get_boolean_value( + "new_checkout", False, {"targeting_key": request.state.user_id} + ) + if enabled: + return checkout_v2() + return checkout_v1() + """ + + __slots__ = ("_of_client",) + + def __init__(self, of_client: object) -> None: + # ``of_client`` is the raw openfeature Client, not ShieldFeatureClient. + self._of_client = of_client + + def get_boolean_value( + self, + flag_key: str, + default: bool, + ctx: object | None = None, + ) -> bool: + """Evaluate a boolean flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_boolean_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_string_value( + self, + flag_key: str, + default: str, + ctx: object | None = None, + ) -> str: + """Evaluate a string flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_string_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_integer_value( + self, + flag_key: str, + default: int, + ctx: object | None = None, + ) -> int: + """Evaluate an integer flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_integer_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_float_value( + self, + flag_key: str, + default: float, + ctx: object | None = None, + ) -> float: + """Evaluate a float flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_float_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_object_value( + self, + flag_key: str, + default: dict, # type: ignore[type-arg] + ctx: object | None = None, + ) -> dict: # type: ignore[type-arg] + """Evaluate a JSON/object flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_object_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + +class ShieldFeatureClient: + """Thin wrapper around the OpenFeature client. + + Instantiated via ``engine.use_openfeature()``. + Do not construct directly. + """ + + def __init__(self, domain: str = "shield") -> None: + from openfeature import api + + self._client = api.get_client(domain) + self._domain = domain + + async def get_boolean_value( + self, + flag_key: str, + default: bool, + ctx: object | None = None, + ) -> bool: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_boolean_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_string_value( + self, + flag_key: str, + default: str, + ctx: object | None = None, + ) -> str: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_string_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_integer_value( + self, + flag_key: str, + default: int, + ctx: object | None = None, + ) -> int: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_integer_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_float_value( + self, + flag_key: str, + default: float, + ctx: object | None = None, + ) -> float: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_float_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_object_value( + self, + flag_key: str, + default: dict[str, Any], + ctx: object | None = None, + ) -> dict[str, Any]: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_object_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type, return-value] + + @property + def sync(self) -> _SyncShieldFeatureClient: + """Return a synchronous façade for use in ``def`` (non-async) handlers. + + Prefer ``engine.sync.flag_client`` over accessing this directly. + """ + return _SyncShieldFeatureClient(self._client) diff --git a/shield/core/feature_flags/evaluator.py b/shield/core/feature_flags/evaluator.py new file mode 100644 index 0000000..f7d6456 --- /dev/null +++ b/shield/core/feature_flags/evaluator.py @@ -0,0 +1,443 @@ +"""Pure feature flag evaluation engine. + +No I/O, no async, no openfeature dependency. Fully unit-testable in +isolation by constructing ``FeatureFlag`` and ``EvaluationContext`` +objects directly. + +Evaluation order +---------------- +1. Flag disabled (``enabled=False``) → ``off_variation`` +2. Prerequisites — recursive, short-circuits on first failure +3. Individual targets — ``flag.targets[variation]`` contains ``ctx.key`` +4. Rules — top-to-bottom, first matching rule wins +5. Fallthrough — fixed variation or percentage rollout bucket + +Clause semantics +---------------- +- All clauses within a rule are AND-ed (all must match). +- Multiple values within one clause are OR-ed (any value must match). +- ``negate=True`` inverts the final result of the clause. + +Rollout bucketing +----------------- +SHA-1 hash of ``"{flag_key}:{ctx.kind}:{ctx.key}"`` modulo 100_000. +Deterministic and stable — the same context always lands in the same +bucket. Weights in ``RolloutVariation`` lists should sum to 100_000. +""" + +from __future__ import annotations + +import hashlib +import logging +import re +from typing import Any + +from shield.core.feature_flags.models import ( + EvaluationContext, + EvaluationReason, + FeatureFlag, + Operator, + ResolutionDetails, + RolloutVariation, + RuleClause, + Segment, + TargetingRule, +) + +logger = logging.getLogger(__name__) + +# Maximum prerequisite recursion depth to prevent accidental infinite loops. +_MAX_PREREQ_DEPTH = 10 + + +class FlagEvaluator: + """Evaluate feature flags against an evaluation context. + + Parameters + ---------- + segments: + Preloaded mapping of segment key → ``Segment``. Pass an empty + dict if no segments are defined. Updated in-place by the + provider on hot-reload. + + Examples + -------- + :: + + evaluator = FlagEvaluator(segments={"beta": beta_segment}) + result = evaluator.evaluate(flag, ctx, all_flags) + print(result.value, result.reason) + """ + + def __init__(self, segments: dict[str, Segment]) -> None: + self._segments = segments + + # ── Public interface ──────────────────────────────────────────────────── + + def evaluate( + self, + flag: FeatureFlag, + ctx: EvaluationContext, + all_flags: dict[str, FeatureFlag], + *, + _depth: int = 0, + ) -> ResolutionDetails: + """Evaluate *flag* for *ctx* and return a ``ResolutionDetails``. + + Parameters + ---------- + flag: + The flag to evaluate. + ctx: + Per-request evaluation context. + all_flags: + Full flag map — required for prerequisite resolution. + _depth: + Internal recursion counter. Do not pass from call sites. + """ + if _depth > _MAX_PREREQ_DEPTH: + logger.error( + "api-shield flags: prerequisite depth limit reached for flag '%s'. " + "Serving off_variation to prevent infinite recursion.", + flag.key, + ) + return self._off( + flag, + reason=EvaluationReason.ERROR, + error_message="Prerequisite depth limit exceeded", + ) + + # Step 1: global kill-switch + if not flag.enabled: + return self._off(flag, reason=EvaluationReason.OFF) + + # Step 2: prerequisites + for prereq in flag.prerequisites: + prereq_flag = all_flags.get(prereq.flag_key) + if prereq_flag is None: + logger.warning( + "api-shield flags: prerequisite flag '%s' not found " + "for flag '%s'. Serving off_variation.", + prereq.flag_key, + flag.key, + ) + return self._off( + flag, + reason=EvaluationReason.PREREQUISITE_FAIL, + prerequisite_key=prereq.flag_key, + ) + prereq_result = self.evaluate(prereq_flag, ctx, all_flags, _depth=_depth + 1) + if prereq_result.variation != prereq.variation: + return self._off( + flag, + reason=EvaluationReason.PREREQUISITE_FAIL, + prerequisite_key=prereq.flag_key, + ) + + # Step 3: individual targets + for variation_name, keys in flag.targets.items(): + if ctx.key in keys: + return ResolutionDetails( + value=flag.get_variation_value(variation_name), + variation=variation_name, + reason=EvaluationReason.TARGET_MATCH, + ) + + # Step 4: targeting rules (top-to-bottom, first match wins) + for rule in flag.rules: + if self._rule_matches(rule, ctx): + variation_name = self._resolve_rule_variation(rule, ctx, flag) + return ResolutionDetails( + value=flag.get_variation_value(variation_name), + variation=variation_name, + reason=EvaluationReason.RULE_MATCH, + rule_id=rule.id, + ) + + # Step 5: fallthrough (default rule) + variation_name = self._resolve_fallthrough(flag, ctx) + return ResolutionDetails( + value=flag.get_variation_value(variation_name), + variation=variation_name, + reason=EvaluationReason.FALLTHROUGH, + ) + + # ── Rule and clause matching ──────────────────────────────────────────── + + def _rule_matches(self, rule: TargetingRule, ctx: EvaluationContext) -> bool: + """Return ``True`` if ALL clauses in *rule* match *ctx* (AND logic).""" + return all(self._clause_matches(clause, ctx) for clause in rule.clauses) + + def _clause_matches(self, clause: RuleClause, ctx: EvaluationContext) -> bool: + """Evaluate a single clause against the context. + + Applies the operator, then inverts the result if ``negate=True``. + Returns ``False`` when the attribute is missing and the operator + requires a value (safe default — missing attribute → no match). + """ + attrs = ctx.all_attributes() + actual = attrs.get(clause.attribute) + result = self._apply_operator(clause.operator, actual, clause.values) + return not result if clause.negate else result + + def _apply_operator(self, op: Operator, actual: Any, values: list[Any]) -> bool: + """Apply *op* comparing *actual* against *values*. + + Multiple values use OR logic — returns ``True`` if any value matches. + Missing ``actual`` (``None``) returns ``False`` for all operators + except ``IS_NOT`` and ``NOT_IN``. + """ + # Segment operators delegate to _in_segment + if op == Operator.IN_SEGMENT: + return any(self._in_segment(actual, seg_key, _ctx=None) for seg_key in values) + if op == Operator.NOT_IN_SEGMENT: + return all(not self._in_segment(actual, seg_key, _ctx=None) for seg_key in values) + + if actual is None: + # Only IS_NOT and NOT_IN make sense with None + if op == Operator.IS_NOT: + return all(v is not None for v in values) + if op == Operator.NOT_IN: + return None not in values + return False + + match op: + # ── Equality ──────────────────────────────────────────────── + case Operator.IS: + return any(actual == v for v in values) + case Operator.IS_NOT: + return all(actual != v for v in values) + # ── String ────────────────────────────────────────────────── + case Operator.CONTAINS: + s = str(actual) + return any(str(v) in s for v in values) + case Operator.NOT_CONTAINS: + s = str(actual) + return all(str(v) not in s for v in values) + case Operator.STARTS_WITH: + s = str(actual) + return any(s.startswith(str(v)) for v in values) + case Operator.ENDS_WITH: + s = str(actual) + return any(s.endswith(str(v)) for v in values) + case Operator.MATCHES: + s = str(actual) + return any(_safe_regex(str(v), s) for v in values) + case Operator.NOT_MATCHES: + s = str(actual) + return all(not _safe_regex(str(v), s) for v in values) + # ── Numeric ───────────────────────────────────────────────── + case Operator.GT: + return _numeric_op(actual, values[0], lambda a, b: a > b) + case Operator.GTE: + return _numeric_op(actual, values[0], lambda a, b: a >= b) + case Operator.LT: + return _numeric_op(actual, values[0], lambda a, b: a < b) + case Operator.LTE: + return _numeric_op(actual, values[0], lambda a, b: a <= b) + # ── Date (ISO-8601 string lexicographic comparison) ────────── + case Operator.BEFORE: + return str(actual) < str(values[0]) + case Operator.AFTER: + return str(actual) > str(values[0]) + # ── Collection ────────────────────────────────────────────── + case Operator.IN: + return actual in values + case Operator.NOT_IN: + return actual not in values + # ── Semantic version ──────────────────────────────────────── + case Operator.SEMVER_EQ: + return _semver_op(actual, values[0], "eq") + case Operator.SEMVER_LT: + return _semver_op(actual, values[0], "lt") + case Operator.SEMVER_GT: + return _semver_op(actual, values[0], "gt") + case _: + logger.warning("api-shield flags: unknown operator '%s'", op) + return False + + # ── Segment evaluation ────────────────────────────────────────────────── + + def _in_segment( + self, + context_key: str | None, + segment_key: str, + *, + _ctx: EvaluationContext | None, + ) -> bool: + """Return ``True`` if *context_key* is a member of *segment_key*. + + Evaluation order: + 1. Key in ``excluded`` → False + 2. Key in ``included`` → True + 3. Any segment rule matches → True + 4. Otherwise → False + """ + if context_key is None: + return False + + seg = self._segments.get(segment_key) + if seg is None: + logger.warning( + "api-shield flags: segment '%s' not found — treating as empty.", + segment_key, + ) + return False + + if context_key in seg.excluded: + return False + if context_key in seg.included: + return True + + if _ctx is None: + # Segment rules need the full context — called from a clause + # that only passed the context key, not the full EvaluationContext. + # Without the full context we can't evaluate rules. + return False + + for rule in seg.rules: + if all(self._clause_matches(clause, _ctx) for clause in rule.clauses): + return True + + return False + + def _clause_matches_with_ctx(self, clause: RuleClause, ctx: EvaluationContext) -> bool: + """Clause match variant that passes *ctx* into segment evaluation.""" + if clause.operator in (Operator.IN_SEGMENT, Operator.NOT_IN_SEGMENT): + actual = ctx.key + if clause.operator == Operator.IN_SEGMENT: + result = any( + self._in_segment(actual, seg_key, _ctx=ctx) for seg_key in clause.values + ) + else: + result = all( + not self._in_segment(actual, seg_key, _ctx=ctx) for seg_key in clause.values + ) + return not result if clause.negate else result + return self._clause_matches(clause, ctx) + + def _rule_matches(self, rule: TargetingRule, ctx: EvaluationContext) -> bool: # type: ignore[no-redef] + """Return ``True`` if ALL clauses in *rule* match *ctx*. + + Uses ``_clause_matches_with_ctx`` so that segment operators receive + the full context for rule evaluation. + """ + return all(self._clause_matches_with_ctx(clause, ctx) for clause in rule.clauses) + + # ── Rollout and variation resolution ─────────────────────────────────── + + def _resolve_rule_variation( + self, rule: TargetingRule, ctx: EvaluationContext, flag: FeatureFlag + ) -> str: + """Return the variation name to serve for a matched rule.""" + if rule.variation is not None: + return rule.variation + if rule.rollout: + return self._bucket_rollout(rule.rollout, ctx, flag.key) + # Malformed rule — fall through to flag default + logger.warning( + "api-shield flags: rule '%s' on flag '%s' has neither variation " + "nor rollout — falling through to default.", + rule.id, + flag.key, + ) + return self._resolve_fallthrough(flag, ctx) + + def _resolve_fallthrough(self, flag: FeatureFlag, ctx: EvaluationContext) -> str: + """Return the variation name for the fallthrough (default) rule.""" + if isinstance(flag.fallthrough, str): + return flag.fallthrough + return self._bucket_rollout(flag.fallthrough, ctx, flag.key) + + @staticmethod + def _bucket_rollout( + rollout: list[RolloutVariation], + ctx: EvaluationContext, + flag_key: str, + ) -> str: + """Deterministic bucket assignment for percentage rollouts. + + Uses SHA-1 of ``"{flag_key}:{ctx.kind}:{ctx.key}"`` for stable, + consistent assignment. Bucket range is 0–99_999 (100_000 total) + matching the weight precision of ``RolloutVariation.weight``. + + Returns the last variation if weights don't sum to 100_000 (safe + fallback — never raises). + """ + seed = f"{flag_key}:{ctx.kind}:{ctx.key}" + bucket = int(hashlib.sha1(seed.encode()).hexdigest(), 16) % 100_000 + cumulative = 0 + for rv in rollout: + cumulative += rv.weight + if bucket < cumulative: + return rv.variation + return rollout[-1].variation + + # ── Helpers ───────────────────────────────────────────────────────────── + + @staticmethod + def _off( + flag: FeatureFlag, + *, + reason: EvaluationReason, + prerequisite_key: str | None = None, + error_message: str | None = None, + ) -> ResolutionDetails: + return ResolutionDetails( + value=flag.get_variation_value(flag.off_variation), + variation=flag.off_variation, + reason=reason, + prerequisite_key=prerequisite_key, + error_message=error_message, + ) + + +# ── Module-level helpers ────────────────────────────────────────────────────── + + +def _safe_regex(pattern: str, string: str) -> bool: + """Apply regex *pattern* to *string*, returning ``False`` on error.""" + try: + return bool(re.search(pattern, string)) + except re.error as exc: + logger.warning("api-shield flags: invalid regex '%s': %s", pattern, exc) + return False + + +def _numeric_op(actual: Any, threshold: Any, comparator: Any) -> bool: + """Apply a numeric comparison, returning ``False`` on type errors.""" + try: + return comparator(float(actual), float(threshold)) # type: ignore[no-any-return] + except (TypeError, ValueError): + return False + + +def _semver_op(actual: Any, threshold: Any, op: str) -> bool: + """Apply a semantic version comparison using ``packaging.version``. + + Falls back to ``False`` if ``packaging`` is not installed or the + version strings are malformed. + """ + try: + from packaging.version import Version + + a = Version(str(actual)) + b = Version(str(threshold)) + if op == "eq": + return a == b + if op == "lt": + return a < b + if op == "gt": + return a > b + except ImportError: + logger.warning( + "api-shield flags: semver operators require 'packaging'. " + "Install with: pip install api-shield[flags]" + ) + except Exception: # noqa: BLE001 + logger.warning( + "api-shield flags: semver comparison failed for values '%s' and '%s'.", + actual, + threshold, + ) + return False diff --git a/shield/core/feature_flags/hooks.py b/shield/core/feature_flags/hooks.py new file mode 100644 index 0000000..f1e6019 --- /dev/null +++ b/shield/core/feature_flags/hooks.py @@ -0,0 +1,168 @@ +"""Built-in OpenFeature hooks for api-shield. + +All hooks implement OpenFeature's ``Hook`` interface and are registered +via ``engine.use_openfeature(hooks=[...])``. + +Built-in hooks registered by default +------------------------------------- +``LoggingHook`` — logs every evaluation at DEBUG level. +``AuditHook`` — records non-trivial evaluations in ShieldEngine's audit log. +``MetricsHook`` — increments per-variation counters for dashboard stats. + +Optional hooks (user-registered) +--------------------------------- +``OpenTelemetryHook`` — sets ``feature_flag.*`` span attributes on the +current OpenTelemetry span. Requires ``opentelemetry-api`` to be installed. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagValueType +from openfeature.hook import Hook, HookContext, HookHints + +logger = logging.getLogger(__name__) + + +class LoggingHook(Hook): + """Log every flag evaluation at DEBUG level. + + Automatically registered by ``engine.use_openfeature()``. + """ + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + logger.debug( + "api-shield flag eval: key=%s variant=%s reason=%s", + hook_context.flag_key, + details.variant, + details.reason, + ) + + def error( + self, + hook_context: HookContext, + exception: Exception, + hints: HookHints, + ) -> None: + logger.error( + "api-shield flag error: key=%s error=%s", + hook_context.flag_key, + exception, + ) + + +class AuditHook(Hook): + """Record flag evaluations in ShieldEngine's audit log. + + Only records evaluations with non-trivial reasons (RULE_MATCH, + TARGET_MATCH, PREREQUISITE_FAIL, ERROR) to avoid polluting the audit + log with FALLTHROUGH and DEFAULT entries. + + Automatically registered by ``engine.use_openfeature()``. + + Parameters + ---------- + engine: + The ``ShieldEngine`` instance to write audit entries to. + """ + + # Reasons worth recording + _RECORD_REASONS = frozenset(["TARGETING_MATCH", "DISABLED", "ERROR"]) + + def __init__(self, engine: Any) -> None: + self._engine = engine + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + if details.reason not in self._RECORD_REASONS: + return + # Fire-and-forget — audit writes are best-effort + import asyncio + import contextlib + + loop = asyncio.get_event_loop() + if loop.is_running(): + with contextlib.suppress(Exception): + loop.create_task( + self._engine.record_flag_evaluation(hook_context.flag_key, details) + ) + + +class MetricsHook(Hook): + """Increment per-variation evaluation counters. + + Parameters + ---------- + collector: + ``FlagMetricsCollector`` instance that stores the counters. + """ + + def __init__(self, collector: Any = None) -> None: + self._collector = collector + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + import asyncio + import contextlib + + ctx = hook_context.evaluation_context + targeting_key = getattr(ctx, "targeting_key", "anonymous") if ctx else "anonymous" + + record = { + "variation": details.variant or "unknown", + "reason": details.reason or "UNKNOWN", + "context_key": targeting_key, + } + + if self._collector is not None: + loop = asyncio.get_event_loop() + if loop.is_running(): + with contextlib.suppress(Exception): + loop.create_task(self._collector.record(hook_context.flag_key, record)) + + +class OpenTelemetryHook(Hook): + """Set ``feature_flag.*`` span attributes on the current OTel span. + + No-ops gracefully when ``opentelemetry-api`` is not installed. + Optional — register via ``engine.use_openfeature(hooks=[OpenTelemetryHook()])``. + """ + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + try: + from opentelemetry import trace # type: ignore[import-not-found] + + span = trace.get_current_span() + if span.is_recording(): + key = hook_context.flag_key + span.set_attribute(f"feature_flag.{key}.value", str(details.value)) + if details.variant: + span.set_attribute(f"feature_flag.{key}.variant", details.variant) + if details.reason: + span.set_attribute(f"feature_flag.{key}.reason", details.reason) + except ImportError: + pass # opentelemetry-api not installed — silently skip diff --git a/shield/core/feature_flags/models.py b/shield/core/feature_flags/models.py new file mode 100644 index 0000000..91f3a0b --- /dev/null +++ b/shield/core/feature_flags/models.py @@ -0,0 +1,590 @@ +"""Feature flag data models for api-shield. + +All models are pure Pydantic v2 with no dependency on ``openfeature``. +This module is importable even without the [flags] extra installed. + +Design notes +------------ +``EvaluationContext.all_attributes()`` merges named convenience fields +(email, ip, country, app_version) with the free-form ``attributes`` dict +so that rule clauses can reference any of them by name without callers +having to manually populate ``attributes`` for common fields. + +``RolloutVariation.weight`` is out of 100_000 (not 100) to allow +fine-grained rollouts like 0.1%, 33.33%, etc. — same precision as +LaunchDarkly. Weights in a rollout list should sum to 100_000. + +``FeatureFlag.targets`` maps variation name → list of context keys for +individual targeting. Evaluated before rules (highest priority after +prerequisites). + +``FeatureFlag.fallthrough`` accepts either a plain variation name +(``str``) for a fixed default, or a list of ``RolloutVariation`` for a +percentage-based default rule. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field + +# ── Flag type ──────────────────────────────────────────────────────────────── + + +class FlagType(StrEnum): + """Value type of a feature flag's variations.""" + + BOOLEAN = "boolean" + STRING = "string" + INTEGER = "integer" + FLOAT = "float" + JSON = "json" + + +# ── Variations ─────────────────────────────────────────────────────────────── + + +class FlagVariation(BaseModel): + """A single named variation of a feature flag. + + Parameters + ---------- + name: + Identifier used in rules, targets, fallthrough, and prerequisites. + E.g. ``"on"``, ``"off"``, ``"control"``, ``"variant_a"``. + value: + The actual value returned when this variation is served. + Must match the flag's ``type``. + description: + Optional human-readable note shown in the dashboard. + """ + + name: str + value: bool | str | int | float | dict[str, Any] | list[Any] + description: str = "" + + +class RolloutVariation(BaseModel): + """One bucket in a percentage rollout. + + Parameters + ---------- + variation: + References ``FlagVariation.name``. + weight: + Share of traffic (out of 100_000). All weights in a rollout + list should sum to 100_000. E.g. 25% = 25_000. + """ + + variation: str + weight: int = Field(ge=0, le=100_000) + + +# ── Targeting operators ────────────────────────────────────────────────────── + + +class Operator(StrEnum): + """All supported targeting rule operators. + + String operators + ---------------- + ``IS`` / ``IS_NOT`` — exact string equality. + ``CONTAINS`` / ``NOT_CONTAINS`` — substring match. + ``STARTS_WITH`` / ``ENDS_WITH`` — prefix / suffix match. + ``MATCHES`` / ``NOT_MATCHES`` — regex match (Python ``re`` module). + + Numeric operators + ----------------- + ``GT`` / ``GTE`` / ``LT`` / ``LTE`` — numeric comparisons. + + Date operators + -------------- + ``BEFORE`` / ``AFTER`` — ISO-8601 string comparisons (lexicographic). + + Collection operators + -------------------- + ``IN`` / ``NOT_IN`` — membership in a list of values. + + Segment operators + ----------------- + ``IN_SEGMENT`` / ``NOT_IN_SEGMENT`` — context is/isn't in a named segment. + + Semantic version operators + -------------------------- + ``SEMVER_EQ`` / ``SEMVER_LT`` / ``SEMVER_GT`` — PEP 440 / semver + comparison using ``packaging.version.Version``. + Requires ``packaging`` (installed with the [flags] extra). + """ + + # Equality + IS = "is" + IS_NOT = "is_not" + # String + CONTAINS = "contains" + NOT_CONTAINS = "not_contains" + STARTS_WITH = "starts_with" + ENDS_WITH = "ends_with" + MATCHES = "matches" + NOT_MATCHES = "not_matches" + # Numeric + GT = "gt" + GTE = "gte" + LT = "lt" + LTE = "lte" + # Date + BEFORE = "before" + AFTER = "after" + # Collection + IN = "in" + NOT_IN = "not_in" + # Segment + IN_SEGMENT = "in_segment" + NOT_IN_SEGMENT = "not_in_segment" + # Semantic version + SEMVER_EQ = "semver_eq" + SEMVER_LT = "semver_lt" + SEMVER_GT = "semver_gt" + + +# ── Rules ──────────────────────────────────────────────────────────────────── + + +class RuleClause(BaseModel): + """A single condition in a targeting rule. + + All clauses within a rule are AND-ed together. + Multiple values within one clause are OR-ed (any value must match). + + Parameters + ---------- + attribute: + Context attribute to inspect. E.g. ``"role"``, ``"plan"``, + ``"email"``, ``"country"``, ``"app_version"``. + operator: + Comparison operator to apply. + values: + One or more values to compare against. Multiple values use + OR logic — the clause passes if *any* value matches. + negate: + When ``True``, the result of the operator check is inverted. + """ + + attribute: str + operator: Operator + values: list[Any] + negate: bool = False + + +class TargetingRule(BaseModel): + """A complete targeting rule: all clauses match → serve a variation. + + Parameters + ---------- + id: + UUID4 identifier. Used for ordering, references, and scheduling. + description: + Human-readable label shown in the dashboard. + clauses: + List of ``RuleClause``. ALL must match (AND logic). + variation: + Fixed variation name to serve when rule matches. + Mutually exclusive with ``rollout``. + rollout: + Percentage rollout when rule matches. + Mutually exclusive with ``variation``. + track_events: + When ``True``, evaluation events for this rule are always + recorded regardless of global event sampling settings. + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = Field(default_factory=list) + variation: str | None = None + rollout: list[RolloutVariation] | None = None + track_events: bool = False + + +# ── Prerequisites ───────────────────────────────────────────────────────────── + + +class Prerequisite(BaseModel): + """A prerequisite flag that must evaluate to a specific variation. + + Parameters + ---------- + flag_key: + Key of the prerequisite flag. + variation: + The variation the prerequisite flag must return. + If it returns any other variation, the dependent flag serves + its ``off_variation``. + """ + + flag_key: str + variation: str + + +# ── Segments ───────────────────────────────────────────────────────────────── + + +class SegmentRule(BaseModel): + """A rule within a segment definition. + + If all clauses match, the context is considered part of the segment. + Multiple segment rules are OR-ed (any matching rule → included). + + Parameters + ---------- + id: + UUID4 identifier for ordering and deletion. + description: + Optional human-readable label shown in the dashboard. + clauses: + List of ``RuleClause``. ALL must match (AND logic) for the rule + to match. + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = Field(default_factory=list) + + +class Segment(BaseModel): + """A reusable group of contexts for flag targeting. + + Evaluation order: + 1. If ``context.key`` is in ``excluded`` → NOT in segment. + 2. If ``context.key`` is in ``included`` → IN segment. + 3. Evaluate ``rules`` top-to-bottom — first match → IN segment. + 4. No match → NOT in segment. + + Parameters + ---------- + key: + Unique identifier. Referenced by ``IN_SEGMENT`` clauses. + name: + Human-readable display name. + included: + Explicit context keys always included in this segment. + excluded: + Explicit context keys always excluded (overrides rules and included). + rules: + Targeting rules — any matching rule means the context is included. + tags: + Organisational labels for filtering in the dashboard. + """ + + key: str + name: str + description: str = "" + included: list[str] = Field(default_factory=list) + excluded: list[str] = Field(default_factory=list) + rules: list[SegmentRule] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +# ── Scheduled changes ───────────────────────────────────────────────────────── + + +class ScheduledChangeAction(StrEnum): + """Action to execute at a scheduled time.""" + + ENABLE = "enable" + DISABLE = "disable" + UPDATE_ROLLOUT = "update_rollout" + ADD_RULE = "add_rule" + DELETE_RULE = "delete_rule" + + +class ScheduledChange(BaseModel): + """A pending change to a flag scheduled for future execution. + + Parameters + ---------- + id: + UUID4 identifier. + execute_at: + UTC datetime when the change should fire. + action: + Which operation to apply to the flag. + payload: + Action-specific data. E.g. for ``UPDATE_ROLLOUT``:: + + {"variation": "on", "weight": 50_000} + + For ``ADD_RULE``: a serialised ``TargetingRule`` dict. + For ``DELETE_RULE``: ``{"rule_id": "..."}``. + created_by: + Actor who scheduled the change (username or ``"system"``). + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + execute_at: datetime + action: ScheduledChangeAction + payload: dict[str, Any] = Field(default_factory=dict) + created_by: str = "system" + created_at: datetime = Field(default_factory=datetime.utcnow) + + +# ── Flag lifecycle status ───────────────────────────────────────────────────── + + +class FlagStatus(StrEnum): + """Computed lifecycle status of a feature flag. + + Derived from evaluation metrics and configuration — not stored. + """ + + NEW = "new" + """Created recently, never evaluated.""" + + ACTIVE = "active" + """Being evaluated or recently modified.""" + + LAUNCHED = "launched" + """Fully rolled out — single variation, stable, safe to clean up.""" + + INACTIVE = "inactive" + """Not evaluated in 7+ days.""" + + DEPRECATED = "deprecated" + """Marked deprecated by an operator. Still evaluated if enabled.""" + + ARCHIVED = "archived" + """Removed from active use. No longer evaluated.""" + + +# ── Full flag definition ────────────────────────────────────────────────────── + + +class FeatureFlag(BaseModel): + """Full definition of a feature flag. + + Stored in ``ShieldBackend`` alongside ``RouteState``. + Backend storage key convention: ``shield:flag:{key}``. + + Parameters + ---------- + key: + Unique identifier. Used in code: ``flags.get_boolean_value("my-flag", ...)``. + name: + Human-readable display name shown in the dashboard. + type: + Determines valid variation value types. + variations: + All possible flag values. Must contain at least two variations. + off_variation: + Variation served when ``enabled=False``. Must match a name in + ``variations``. + fallthrough: + Default rule when no targeting rule matches. Either a fixed + variation name (``str``) or a percentage rollout + (``list[RolloutVariation]`` summing to 100_000). + enabled: + Global kill-switch. When ``False``, all requests receive + ``off_variation`` regardless of targeting rules. + prerequisites: + Other flags that must evaluate to specific variations before this + flag's rules run. Evaluated recursively. Circular dependencies + are prevented at write time. + targets: + Individual targeting. Maps variation name → list of context keys + that always receive that variation. Evaluated after prerequisites, + before rules. + rules: + Targeting rules evaluated top-to-bottom. First match wins. + scheduled_changes: + Pending future mutations managed by ``FlagScheduler``. + temporary: + When ``True``, the flag hygiene system may mark it for removal + once it reaches ``LAUNCHED`` or ``INACTIVE`` status. + maintainer: + Username of the person responsible for this flag. + """ + + key: str + name: str + description: str = "" + type: FlagType + tags: list[str] = Field(default_factory=list) + + variations: list[FlagVariation] + off_variation: str + fallthrough: str | list[RolloutVariation] + + enabled: bool = True + prerequisites: list[Prerequisite] = Field(default_factory=list) + targets: dict[str, list[str]] = Field(default_factory=dict) + rules: list[TargetingRule] = Field(default_factory=list) + scheduled_changes: list[ScheduledChange] = Field(default_factory=list) + + status: FlagStatus = FlagStatus.ACTIVE + temporary: bool = True + maintainer: str | None = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + created_by: str = "system" + + def get_variation_value(self, name: str) -> Any: + """Return the value for the variation with the given name. + + Returns ``None`` if the variation name is not found — callers + should validate variation names at write time. + """ + for v in self.variations: + if v.name == name: + return v.value + return None + + def variation_names(self) -> list[str]: + """Return all variation names for this flag.""" + return [v.name for v in self.variations] + + +# ── Evaluation context ──────────────────────────────────────────────────────── + + +class EvaluationContext(BaseModel): + """Per-request context used for flag targeting. + + This is the primary object application code constructs and passes to + ``ShieldFeatureClient.get_*_value()``. + + Parameters + ---------- + key: + Required unique identifier for the entity being evaluated. + Typically ``user_id``, ``session_id``, or ``org_id``. Used for + individual targeting and deterministic rollout bucketing. + kind: + Context kind. Defaults to ``"user"``. Use ``"organization"``, + ``"device"``, or a custom string for non-user contexts. + email: + Convenience field — accessible in rules as ``"email"`` attribute. + ip: + Convenience field — accessible in rules as ``"ip"`` attribute. + country: + Convenience field — accessible in rules as ``"country"`` attribute. + app_version: + Convenience field — accessible in rules as ``"app_version"``. + Use semver operators for version-based targeting. + attributes: + Arbitrary additional attributes. Keys must be strings. + Values can be any JSON-serialisable type. + + Examples + -------- + Minimal context:: + + ctx = EvaluationContext(key=request.headers["x-user-id"]) + + Rich context:: + + ctx = EvaluationContext( + key=user.id, + kind="user", + email=user.email, + country=user.country, + app_version="2.3.1", + attributes={"plan": user.plan, "role": user.role}, + ) + """ + + key: str + kind: str = "user" + email: str | None = None + ip: str | None = None + country: str | None = None + app_version: str | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + def all_attributes(self) -> dict[str, Any]: + """Merge named convenience fields with ``attributes`` for rule evaluation. + + Named fields take lower priority than ``attributes`` — if the same + key appears in both, ``attributes`` wins. + + Returns + ------- + dict[str, Any] + Flat dict of all context attributes, including ``"key"`` and + ``"kind"`` as first-class attributes. + """ + base: dict[str, Any] = {"key": self.key, "kind": self.kind} + for field_name in ("email", "ip", "country", "app_version"): + val = getattr(self, field_name) + if val is not None: + base[field_name] = val + return {**base, **self.attributes} + + +# ── Resolution result ───────────────────────────────────────────────────────── + + +class EvaluationReason(StrEnum): + """Why a flag returned the value it did. + + Included in ``ResolutionDetails`` for every evaluation. + Used by the live events stream, audit hook, and eval debugger. + """ + + OFF = "OFF" + """Flag is globally disabled. ``off_variation`` was served.""" + + FALLTHROUGH = "FALLTHROUGH" + """No targeting rule matched. Default rule was served.""" + + TARGET_MATCH = "TARGET_MATCH" + """Context key was in the individual targets list.""" + + RULE_MATCH = "RULE_MATCH" + """A targeting rule matched. See ``rule_id``.""" + + PREREQUISITE_FAIL = "PREREQUISITE_FAIL" + """A prerequisite flag did not return the required variation. + See ``prerequisite_key``.""" + + ERROR = "ERROR" + """Provider or evaluation error. Default value was returned.""" + + DEFAULT = "DEFAULT" + """Flag not found in provider. SDK default was returned.""" + + +class ResolutionDetails(BaseModel): + """Full result of a feature flag evaluation. + + Application code usually only needs ``.value``. The extra fields + are used by hooks, the dashboard live stream, and the eval debugger. + + Parameters + ---------- + value: + The resolved flag value. + variation: + The variation name that was served. ``None`` on error/default. + reason: + Why this value was returned. + rule_id: + The ``TargetingRule.id`` that matched. Only set when + ``reason == RULE_MATCH``. + prerequisite_key: + The flag key of the failing prerequisite. Only set when + ``reason == PREREQUISITE_FAIL``. + error_message: + Human-readable error detail. Only set when ``reason == ERROR``. + """ + + value: Any + variation: str | None = None + reason: EvaluationReason + rule_id: str | None = None + prerequisite_key: str | None = None + error_message: str | None = None diff --git a/shield/core/feature_flags/provider.py b/shield/core/feature_flags/provider.py new file mode 100644 index 0000000..c63e6a4 --- /dev/null +++ b/shield/core/feature_flags/provider.py @@ -0,0 +1,199 @@ +"""ShieldOpenFeatureProvider — native OpenFeature provider backed by ShieldBackend. + +Phase 2 implementation. Stub present so the package imports cleanly. +Full implementation wires FlagEvaluator into the OpenFeature resolution API. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider import AbstractProvider +from openfeature.provider.metadata import Metadata + +from shield.core.feature_flags._context import from_of_context +from shield.core.feature_flags.evaluator import FlagEvaluator +from shield.core.feature_flags.models import ( + EvaluationReason, + FeatureFlag, + Segment, +) + +if TYPE_CHECKING: + from shield.core.backends.base import ShieldBackend + +logger = logging.getLogger(__name__) + +# Map Shield EvaluationReason → OpenFeature Reason string +_REASON_MAP: dict[EvaluationReason, str] = { + EvaluationReason.OFF: Reason.DISABLED, + EvaluationReason.FALLTHROUGH: Reason.DEFAULT, + EvaluationReason.TARGET_MATCH: Reason.TARGETING_MATCH, + EvaluationReason.RULE_MATCH: Reason.TARGETING_MATCH, + EvaluationReason.PREREQUISITE_FAIL: Reason.DISABLED, + EvaluationReason.ERROR: Reason.ERROR, + EvaluationReason.DEFAULT: Reason.DEFAULT, +} + + +class ShieldOpenFeatureProvider(AbstractProvider): + """OpenFeature-compliant provider backed by ``ShieldBackend``. + + Stores ``FeatureFlag`` and ``Segment`` objects in the same backend + as ``RouteState`` — no separate infrastructure required. + + Subscribes to backend pub/sub for instant hot-reload on flag changes. + Evaluates flags locally using ``FlagEvaluator`` — zero network calls + per evaluation. + + Parameters + ---------- + backend: + The ``ShieldBackend`` instance (Memory, File, or Redis). + Must be the same instance passed to ``ShieldEngine``. + """ + + def __init__(self, backend: ShieldBackend) -> None: + self._backend = backend + self._flags: dict[str, FeatureFlag] = {} + self._segments: dict[str, Segment] = {} + self._evaluator = FlagEvaluator(segments=self._segments) + + def get_metadata(self) -> Metadata: + return Metadata(name="shield") + + def get_provider_hooks(self) -> list[Any]: + return [] + + def initialize(self, evaluation_context: Any = None) -> None: + """No-op sync hook required by the OpenFeature SDK registry. + + The OpenFeature SDK calls this synchronously when ``set_provider()`` + is invoked. Actual async initialisation (loading flags from the + backend) is performed by ``engine.start()`` via ``_load_all()``. + """ + + def shutdown(self) -> None: + """No-op sync hook required by the OpenFeature SDK registry.""" + + async def _load_all(self) -> None: + """Load all flags and segments from backend into local cache.""" + try: + flags = await self._backend.load_all_flags() + self._flags = {f.key: f for f in flags} + segments = await self._backend.load_all_segments() + self._segments.update({s.key: s for s in segments}) + except AttributeError: + # Backend does not yet support flag storage (pre-Phase 3 backends). + # Operate with empty caches — all evaluations return defaults. + logger.debug( + "api-shield flags: backend does not support flag storage yet. " + "All flag evaluations will return defaults." + ) + + # ── OpenFeature resolution methods ────────────────────────────────────── + + def resolve_boolean_details( + self, flag_key: str, default_value: bool, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, bool) + + def resolve_string_details( + self, flag_key: str, default_value: str, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, str) + + def resolve_integer_details( + self, flag_key: str, default_value: int, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, int) + + def resolve_float_details( + self, flag_key: str, default_value: float, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, float) + + def resolve_object_details( # type: ignore[override] + self, + flag_key: str, + default_value: dict[str, Any], + evaluation_context: Any = None, + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, dict) + + # ── Internal ──────────────────────────────────────────────────────────── + + def _resolve( + self, + flag_key: str, + default_value: Any, + of_ctx: Any, + expected_type: type, + ) -> FlagResolutionDetails[Any]: + flag = self._flags.get(flag_key) + if flag is None: + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message=f"Flag '{flag_key}' not found", + ) + + ctx = from_of_context(of_ctx) + try: + result = self._evaluator.evaluate(flag, ctx, self._flags) + except Exception as exc: # noqa: BLE001 + logger.exception("api-shield flags: evaluation error for '%s'", flag_key) + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message=str(exc), + ) + + value = result.value + # Type coercion — ensure returned value matches the expected type + if value is None: + value = default_value + else: + try: + value = expected_type(value) + except (TypeError, ValueError): + value = default_value + + flag_metadata: dict[str, int | float | str] = {} + if result.rule_id is not None: + flag_metadata["rule_id"] = result.rule_id + if result.prerequisite_key is not None: + flag_metadata["prerequisite_key"] = result.prerequisite_key + return FlagResolutionDetails( + value=value, + variant=result.variation, + reason=_REASON_MAP.get(result.reason, Reason.UNKNOWN), + flag_metadata=flag_metadata, + ) + + # ── Flag cache management (called by engine on flag CRUD) ──────────────── + + def upsert_flag(self, flag: FeatureFlag) -> None: + """Update or insert a flag in the local cache.""" + self._flags[flag.key] = flag + + def delete_flag(self, flag_key: str) -> None: + """Remove a flag from the local cache.""" + self._flags.pop(flag_key, None) + + def upsert_segment(self, segment: Segment) -> None: + """Update or insert a segment in the local cache.""" + self._segments[segment.key] = segment + + def delete_segment(self, segment_key: str) -> None: + """Remove a segment from the local cache.""" + self._segments.pop(segment_key, None) diff --git a/shield/core/feature_flags/scheduler.py b/shield/core/feature_flags/scheduler.py new file mode 100644 index 0000000..8905740 --- /dev/null +++ b/shield/core/feature_flags/scheduler.py @@ -0,0 +1,233 @@ +"""FlagScheduler — asyncio.Task-based scheduled flag change runner. + +Each :class:`ScheduledChange` on a :class:`FeatureFlag` gets one asyncio +task that sleeps until ``execute_at``, applies the action to the flag, then +removes the change from the flag's ``scheduled_changes`` list. + +On startup the scheduler scans all flags and re-creates tasks for any +pending changes whose ``execute_at`` is still in the future (restart +recovery). + +Supported :class:`~shield.core.feature_flags.models.ScheduledChangeAction`\\ s: + +* ``ENABLE`` — sets ``flag.enabled = True`` +* ``DISABLE`` — sets ``flag.enabled = False`` +* ``UPDATE_ROLLOUT`` — replaces ``flag.fallthrough`` with a new variation + name or rollout list from ``payload`` +* ``ADD_RULE`` — appends a :class:`TargetingRule` parsed from ``payload`` +* ``DELETE_RULE`` — removes the rule with ``payload["rule_id"]`` +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +import anyio + +if TYPE_CHECKING: + from shield.core.engine import ShieldEngine + +logger = logging.getLogger(__name__) + + +class FlagScheduler: + """Manages scheduled flag changes using ``asyncio.Task`` objects. + + Parameters + ---------- + engine: + The :class:`~shield.core.engine.ShieldEngine` used to read and + write flags. + """ + + def __init__(self, engine: ShieldEngine) -> None: + self._engine = engine + # (flag_key, change_id) → running task + self._tasks: dict[tuple[str, str], asyncio.Task[None]] = {} + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Restore pending scheduled changes from all flags. + + Called by ``ShieldEngine.start()`` when feature flags are enabled. + """ + try: + flags = await self._engine.list_flags() + except Exception: + logger.exception("FlagScheduler: failed to load flags on startup") + return + + now = datetime.now(UTC) + count = 0 + for flag in flags: + for change in list(flag.scheduled_changes): + execute_at = change.execute_at + if execute_at.tzinfo is None: + execute_at = execute_at.replace(tzinfo=UTC) + if execute_at > now: + self._create_task(flag.key, change) + count += 1 + if count: + logger.info("FlagScheduler: restored %d pending scheduled change(s)", count) + + async def stop(self) -> None: + """Cancel all pending scheduled change tasks.""" + for task in list(self._tasks.values()): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + self._tasks.clear() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def schedule(self, flag_key: str, change: Any) -> None: + """Register a new scheduled change task. + + If a task already exists for ``(flag_key, change.id)`` it is + cancelled and replaced. + + Parameters + ---------- + flag_key: + Key of the flag that owns the change. + change: + A :class:`~shield.core.feature_flags.models.ScheduledChange` + instance already appended to the flag's ``scheduled_changes`` + list and persisted to the backend. + """ + await self.cancel(flag_key, change.id) + self._create_task(flag_key, change) + + async def cancel(self, flag_key: str, change_id: str) -> None: + """Cancel the task for a specific scheduled change, if any.""" + task = self._tasks.pop((flag_key, change_id), None) + if task is not None: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + async def cancel_all_for_flag(self, flag_key: str) -> None: + """Cancel all pending tasks for *flag_key* (e.g. when a flag is deleted).""" + keys_to_cancel = [k for k in self._tasks if k[0] == flag_key] + for k in keys_to_cancel: + task = self._tasks.pop(k) + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + def list_pending(self) -> list[dict[str, str]]: + """Return a list of ``{"flag_key": ..., "change_id": ...}`` dicts.""" + return [{"flag_key": fk, "change_id": cid} for fk, cid in self._tasks] + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _create_task(self, flag_key: str, change: Any) -> asyncio.Task[None]: + task = asyncio.create_task( + self._run_change(flag_key, change), + name=f"shield-flag-scheduler:{flag_key}:{change.id}", + ) + self._tasks[(flag_key, change.id)] = task + task.add_done_callback(lambda t: self._tasks.pop((flag_key, change.id), None)) + return task + + async def _run_change(self, flag_key: str, change: Any) -> None: + """Sleep until ``execute_at``, then apply the change to the flag.""" + execute_at = change.execute_at + if execute_at.tzinfo is None: + execute_at = execute_at.replace(tzinfo=UTC) + + now = datetime.now(UTC) + delay = (execute_at - now).total_seconds() + if delay > 0: + try: + await anyio.sleep(delay) + except asyncio.CancelledError: + return + + logger.info( + "FlagScheduler: executing change %s (action=%s) on flag %r", + change.id, + change.action, + flag_key, + ) + try: + await self._apply_change(flag_key, change) + except Exception: + logger.exception( + "FlagScheduler: error applying change %s on flag %r", change.id, flag_key + ) + + async def _apply_change(self, flag_key: str, change: Any) -> None: + """Load the flag, mutate it, remove the change, and persist.""" + from shield.core.feature_flags.models import ScheduledChangeAction, TargetingRule + + flag = await self._engine.get_flag(flag_key) + if flag is None: + logger.warning( + "FlagScheduler: flag %r not found when applying change %s — skipping", + flag_key, + change.id, + ) + return + + action = change.action + payload = change.payload or {} + + if action == ScheduledChangeAction.ENABLE: + flag = flag.model_copy(update={"enabled": True}) + elif action == ScheduledChangeAction.DISABLE: + flag = flag.model_copy(update={"enabled": False}) + elif action == ScheduledChangeAction.UPDATE_ROLLOUT: + new_fallthrough = payload.get("variation") or payload.get("rollout") + if new_fallthrough is not None: + flag = flag.model_copy(update={"fallthrough": new_fallthrough}) + else: + logger.warning( + "FlagScheduler: UPDATE_ROLLOUT payload missing 'variation' for flag %r", + flag_key, + ) + elif action == ScheduledChangeAction.ADD_RULE: + try: + new_rule = TargetingRule.model_validate(payload) + updated_rules = list(flag.rules) + [new_rule] + flag = flag.model_copy(update={"rules": updated_rules}) + except Exception as exc: + logger.error( + "FlagScheduler: ADD_RULE payload invalid for flag %r: %s", flag_key, exc + ) + return + elif action == ScheduledChangeAction.DELETE_RULE: + rule_id = payload.get("rule_id") + updated_rules = [r for r in flag.rules if r.id != rule_id] + flag = flag.model_copy(update={"rules": updated_rules}) + else: + logger.warning("FlagScheduler: unknown action %r for flag %r", action, flag_key) + return + + # Remove the executed change from the flag's scheduled_changes list. + remaining = [c for c in flag.scheduled_changes if c.id != change.id] + flag = flag.model_copy(update={"scheduled_changes": remaining}) + await self._engine.save_flag(flag) + + logger.info( + "FlagScheduler: applied %s to flag %r (change %s)", + action, + flag_key, + change.id, + ) 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..0e5f6cd 100644 --- a/shield/dashboard/app.py +++ b/shield/dashboard/app.py @@ -65,6 +65,35 @@ 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", "") + # Translate internal sentinel keys to human-friendly labels + if raw == "__global__": + return "[Global Maintenance]" + if raw == "__global_rl__": + return "[Global Rate Limit]" + if raw.startswith("__shield:svc_global:") and raw.endswith("__"): + name = raw[len("__shield:svc_global:") : -2] + return f"[{name} Maintenance]" + if raw.startswith("__shield:svc_rl:") and raw.endswith("__"): + name = raw[len("__shield:svc_rl:") : -2] + return f"[{name} Rate Limit]" + 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 @@ -80,9 +109,13 @@ def ShieldDashboard( Route("/routes", r.routes_partial), Route("/modal/global/enable", r.modal_global_enable), Route("/modal/global/disable", r.modal_global_disable), + Route("/modal/service/enable", r.modal_service_enable), + Route("/modal/service/disable", r.modal_service_disable), Route("/modal/{action}/{path_key}", r.action_modal), Route("/global-maintenance/enable", r.global_maintenance_enable, methods=["POST"]), Route("/global-maintenance/disable", r.global_maintenance_disable, methods=["POST"]), + Route("/service-maintenance/enable", r.service_maintenance_enable, methods=["POST"]), + Route("/service-maintenance/disable", r.service_maintenance_disable, methods=["POST"]), Route("/toggle/{path_key}", r.toggle, methods=["POST"]), Route("/disable/{path_key}", r.disable, methods=["POST"]), Route("/enable/{path_key}", r.enable, methods=["POST"]), diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py index b1bd7b1..8ffffdb 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,8 +178,13 @@ 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() + service_config = await engine.get_service_maintenance(service) if service else None # 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). rl_by_path: dict[str, object] = {} @@ -167,12 +200,15 @@ async def index(request: Request) -> Response: "states": paged["items"], "pagination": paged, "global_config": global_config, + "service_config": service_config, "rate_limit_policies": rl_by_path, "prefix": prefix, "active_tab": "routes", "version": request.app.state.version, "path_slug": path_slug, "shield_actor": _actor(request), + "services": services, + "selected_service": service, }, ) @@ -183,7 +219,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 +230,7 @@ async def routes_partial(request: Request) -> Response: "states": states, "prefix": prefix, "path_slug": path_slug, + "selected_service": service, }, ) @@ -316,7 +356,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 +373,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 +384,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}, ) @@ -407,6 +457,85 @@ async def global_maintenance_disable(request: Request) -> HTMLResponse: return HTMLResponse(_render_global_widget(tpl, config, prefix)) +def _render_service_widget(tpl: Jinja2Templates, config: object, service: str, prefix: str) -> str: + """Render the per-service maintenance status widget partial.""" + return tpl.env.get_template("partials/service_maintenance.html").render( + config=config, + service=service, + prefix=prefix, + ) + + +async def modal_service_enable(request: Request) -> HTMLResponse: + """Return the per-service maintenance enable modal form.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_enable.html").render( + prefix=prefix, service=service + ) + return HTMLResponse(html) + + +async def modal_service_disable(request: Request) -> HTMLResponse: + """Return the per-service maintenance disable confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_disable.html").render( + prefix=prefix, service=service + ) + return HTMLResponse(html) + + +async def service_maintenance_enable(request: Request) -> HTMLResponse: + """Enable per-service maintenance from form data.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + form = await request.form() + service = str(form.get("service", "")) + reason = str(form.get("reason", "")) + exempt_raw = str(form.get("exempt_paths", "")) + exempt_paths = [p.strip() for p in exempt_raw.splitlines() if p.strip()] + include_force_active = form.get("include_force_active") == "1" + + if not service: + return HTMLResponse("Missing service", status_code=400) + + await engine.enable_service_maintenance( + service=service, + reason=reason, + exempt_paths=exempt_paths, + include_force_active=include_force_active, + actor=_actor(request), + platform=_platform(request), + ) + config = await engine.get_service_maintenance(service) + return HTMLResponse(_render_service_widget(tpl, config, service, prefix)) + + +async def service_maintenance_disable(request: Request) -> HTMLResponse: + """Disable per-service maintenance from form data.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + + form = await request.form() + service = str(form.get("service", "")) + if not service: + return HTMLResponse("Missing service", status_code=400) + + await engine.disable_service_maintenance( + service=service, + actor=_actor(request), + platform=_platform(request), + ) + config = await engine.get_service_maintenance(service) + return HTMLResponse(_render_service_widget(tpl, config, service, prefix)) + + async def modal_env_gate(request: Request) -> HTMLResponse: """Return the env-gate modal form pre-filled with the current allowed envs.""" engine = _engine(request) @@ -500,9 +629,21 @@ 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() + service_rl = await engine.get_service_rate_limit(service) if service else None + unrated_routes = _get_unrated_routes(states, engine._rate_limit_policies, service) return tpl.TemplateResponse( request, "rate_limits.html", @@ -510,10 +651,14 @@ async def rate_limits_page(request: Request) -> Response: "policies": paged["items"], "pagination": paged, "global_rl": global_rl, + "service_rl": service_rl, "prefix": prefix, "active_tab": "rate_limits", "version": request.app.state.version, "shield_actor": _actor(request), + "services": services, + "selected_service": service, + "unrated_routes": unrated_routes, }, ) @@ -525,7 +670,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 +692,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 +705,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 +781,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 +914,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("") @@ -787,16 +1058,173 @@ async def global_rl_disable(request: Request) -> HTMLResponse: return HTMLResponse(html) +# --------------------------------------------------------------------------- +# Per-service rate limit dashboard handlers +# --------------------------------------------------------------------------- + + +async def modal_service_rl(request: Request) -> HTMLResponse: + """Return the per-service rate limit set/edit modal form.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/modal_service_rl.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def modal_service_rl_delete(request: Request) -> HTMLResponse: + """Return the per-service rate limit delete confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_rl_delete.html").render( + service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def modal_service_rl_reset(request: Request) -> HTMLResponse: + """Return the per-service rate limit reset confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + service = request.query_params.get("service", "") + html = tpl.env.get_template("partials/modal_service_rl_reset.html").render( + service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_set(request: Request) -> HTMLResponse: + """Save per-service rate limit policy from form data and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + limit = str(form.get("limit", "")).strip() + algorithm = str(form.get("algorithm", "fixed_window")).strip() or None + key_strategy = str(form.get("key_strategy", "ip")).strip() or None + burst = int(str(form.get("burst", 0) or 0)) + exempt_raw = str(form.get("exempt_routes", "")).strip() + exempt_routes = [r.strip() for r in exempt_raw.splitlines() if r.strip()] + if limit and service: + try: + await engine.set_service_rate_limit( + service, + limit=limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + exempt_routes=exempt_routes, + actor=_actor(request), + platform=_platform(request), + ) + except Exception: + pass + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_delete(request: Request) -> HTMLResponse: + """Delete per-service rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.delete_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_reset(request: Request) -> HTMLResponse: + """Reset per-service rate limit counters and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.reset_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_enable(request: Request) -> HTMLResponse: + """Enable (resume) per-service rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.enable_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + +async def service_rl_disable(request: Request) -> HTMLResponse: + """Disable (pause) per-service rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + service = str(form.get("service", "")).strip() + if service: + await engine.disable_service_rate_limit( + service, actor=_actor(request), platform=_platform(request) + ) + srl = await engine.get_service_rate_limit(service) + html = tpl.env.get_template("partials/service_rl_card.html").render( + srl=srl, service=service, prefix=prefix + ) + return HTMLResponse(html) + + async def rate_limits_hits_partial(request: Request) -> Response: """Return only the recent blocked requests table rows (HTMX auto-refresh).""" 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 +1248,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: ". @@ -838,6 +1269,8 @@ async def _generate() -> object: # Keepalive ping loop — runs when subscribe() is unsupported OR after # the subscription ends. Browsers keep the connection alive. while True: + if await request.is_disconnected(): + break yield ": keepalive\n\n" try: await anyio.sleep(15) @@ -852,3 +1285,855 @@ async def _generate() -> object: "X-Accel-Buffering": "no", }, ) + + +# --------------------------------------------------------------------------- +# Feature flag dashboard pages +# --------------------------------------------------------------------------- + +_FLAG_TYPE_COLOURS = { + "boolean": "emerald", + "string": "blue", + "integer": "violet", + "float": "violet", + "json": "amber", +} + + +async def flags_page(request: Request) -> Response: + """GET /flags — feature flag list page.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + flags = await engine.list_flags() + return tpl.TemplateResponse( + request, + "flags.html", + { + "prefix": prefix, + "flags": flags, + "active_tab": "flags", + "shield_actor": _actor(request), + "version": request.app.state.version, + "flag_type_colours": _FLAG_TYPE_COLOURS, + "flags_enabled": True, + }, + ) + + +async def flags_rows_partial(request: Request) -> Response: + """GET /flags/rows — HTMX partial: flag table rows only. + + Supports ``?q=`` search query and ``?type=`` / ``?status=`` filters. + """ + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + flags = await engine.list_flags() + + q = request.query_params.get("q", "").lower().strip() + ftype = request.query_params.get("type", "").strip() + status_filter = request.query_params.get("status", "").strip() + + if q: + flags = [f for f in flags if q in f.key.lower() or q in (f.name or "").lower()] + if ftype: + flags = [f for f in flags if f.type.value == ftype] + if status_filter == "enabled": + flags = [f for f in flags if f.enabled] + elif status_filter == "disabled": + flags = [f for f in flags if not f.enabled] + + return tpl.TemplateResponse( + request, + "partials/flag_rows.html", + { + "prefix": prefix, + "flags": flags, + "flag_type_colours": _FLAG_TYPE_COLOURS, + }, + ) + + +async def flag_detail_page(request: Request) -> Response: + """GET /flags/{key} — single flag detail page.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + segments = await engine.list_segments() + all_flags = await engine.list_flags() + return tpl.TemplateResponse( + request, + "flag_detail.html", + { + "prefix": prefix, + "flag": flag, + "segments": segments, + "all_flags": [f for f in all_flags if f.key != key], + "active_tab": "flags", + "shield_actor": _actor(request), + "version": request.app.state.version, + "flag_type_colours": _FLAG_TYPE_COLOURS, + "flags_enabled": True, + }, + ) + + +async def flag_enable(request: Request) -> Response: + """POST /flags/{key}/enable — enable a flag; return updated row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("Flag not found", status_code=404) + flag = flag.model_copy(update={"enabled": True}) + await engine.save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_enabled" + ) + return tpl.TemplateResponse( + request, + "partials/flag_row.html", + {"prefix": prefix, "flag": flag, "flag_type_colours": _FLAG_TYPE_COLOURS}, + ) + + +async def flag_disable(request: Request) -> Response: + """POST /flags/{key}/disable — disable a flag; return updated row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("Flag not found", status_code=404) + flag = flag.model_copy(update={"enabled": False}) + await engine.save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_disabled" + ) + return tpl.TemplateResponse( + request, + "partials/flag_row.html", + {"prefix": prefix, "flag": flag, "flag_type_colours": _FLAG_TYPE_COLOURS}, + ) + + +async def flag_delete(request: Request) -> Response: + """DELETE /flags/{key} — delete a flag; return empty response (HTMX removes row).""" + engine = _engine(request) + key = request.path_params["key"] + await engine.delete_flag(key, actor=_actor(request), platform=_platform(request)) + return HTMLResponse("") + + +async def modal_flag_create(request: Request) -> Response: + """GET /modal/flag/create — return create flag modal HTML.""" + tpl = _templates(request) + prefix = _prefix(request) + return tpl.TemplateResponse( + request, + "partials/modal_flag_create.html", + {"prefix": prefix}, + ) + + +async def flag_create_form(request: Request) -> Response: + """POST /flags/create — create a flag from form data; return new row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + form = await request.form() + key = str(form.get("key", "")).strip() + name = str(form.get("name", "")).strip() + ftype = str(form.get("type", "boolean")).strip() + + if not key or not name: + return HTMLResponse( + "

Key and name are required.

", + status_code=400, + ) + + from shield.core.feature_flags.models import FeatureFlag, FlagType, FlagVariation + + type_map = { + "boolean": ( + FlagType.BOOLEAN, + [FlagVariation(name="on", value=True), FlagVariation(name="off", value=False)], + "off", + "off", + ), + "string": ( + FlagType.STRING, + [ + FlagVariation(name="control", value="control"), + FlagVariation(name="treatment", value="treatment"), + ], + "control", + "control", + ), + "integer": ( + FlagType.INTEGER, + [FlagVariation(name="off", value=0), FlagVariation(name="on", value=1)], + "off", + "off", + ), + "float": ( + FlagType.FLOAT, + [FlagVariation(name="off", value=0.0), FlagVariation(name="on", value=1.0)], + "off", + "off", + ), + "json": ( + FlagType.JSON, + [FlagVariation(name="off", value={}), FlagVariation(name="on", value={})], + "off", + "off", + ), + } + if ftype not in type_map: + ftype = "boolean" + ft, variations, off_var, fallthrough = type_map[ftype] + + flag = FeatureFlag( + key=key, + name=name, + type=ft, + variations=variations, + off_variation=off_var, + fallthrough=fallthrough, + enabled=True, + ) + await engine.save_flag(flag, actor=_actor(request), platform=_platform(request)) + return tpl.TemplateResponse( + request, + "partials/flag_row.html", + {"prefix": prefix, "flag": flag, "flag_type_colours": _FLAG_TYPE_COLOURS}, + headers={"HX-Trigger": "flagCreated"}, + ) + + +async def modal_flag_eval(request: Request) -> Response: + """GET /modal/flag/{key}/eval — return eval debugger modal HTML.""" + tpl = _templates(request) + prefix = _prefix(request) + key = request.path_params["key"] + engine = _engine(request) + flag = await engine.get_flag(key) + return tpl.TemplateResponse( + request, + "partials/modal_flag_eval.html", + {"prefix": prefix, "flag": flag, "key": key}, + ) + + +async def flag_eval_form(request: Request) -> Response: + """POST /flags/{key}/eval — evaluate flag from form data; return rich result partial.""" + import json as _json + + from shield.core.feature_flags.evaluator import FlagEvaluator + from shield.core.feature_flags.models import EvaluationContext + + tpl = _templates(request) + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + ctx_key = str(form.get("context_key", "anonymous")).strip() or "anonymous" + kind = str(form.get("kind", "user")).strip() or "user" + attrs_raw = str(form.get("attributes", "")).strip() + attributes: dict[str, str] = {} + for line in attrs_raw.splitlines(): + line = line.strip() + if "=" in line: + k, _, v = line.partition("=") + attributes[k.strip()] = v.strip() + + ctx = EvaluationContext(key=ctx_key, kind=kind, attributes=attributes) + all_flags_list = await engine.list_flags() + all_flags = {f.key: f for f in all_flags_list} + segments_list = await engine.list_segments() + segments = {s.key: s for s in segments_list} + evaluator = FlagEvaluator(segments=segments) + result = evaluator.evaluate(flag, ctx, all_flags) + + # Look up rule description for RULE_MATCH + rule_description = "" + if result.rule_id: + for rule in flag.rules: + if rule.id == result.rule_id: + rule_description = rule.description or "" + break + + # Serialize value as JSON for display (handles bool, dict, list, etc.) + try: + value_json = _json.dumps(result.value) + except (TypeError, ValueError): + value_json = str(result.value) + + trigger = _json.dumps( + { + "shieldEvalDone": { + "flagKey": key, + "value": result.value, + "reason": result.reason.value, + "error": bool(result.error_message), + "errorMessage": result.error_message or "", + } + } + ) + return tpl.TemplateResponse( + request, + "partials/flag_eval_result.html", + { + "result": result, + "rule_description": rule_description, + "value_json": value_json, + "ctx_key": ctx_key, + "ctx_kind": kind, + "ctx_attributes": attributes, + }, + headers={"HX-Trigger": trigger}, + ) + + +async def flag_settings_save(request: Request) -> Response: + """POST /flags/{key}/settings/save — update flag name and description.""" + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + form = await request.form() + name = str(form.get("name", flag.name)).strip() or flag.name + description = str(form.get("description", flag.description or "")).strip() + updated = flag.model_copy(update={"name": name, "description": description}) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Settings saved
", + headers={"HX-Trigger": '{"flagSettingsSaved": true}'}, + ) + + +async def flag_variations_save(request: Request) -> Response: + """POST /flags/{key}/variations/save — replace flag variations.""" + import json as _json + import re as _re + + from shield.core.feature_flags.models import FlagType, FlagVariation + + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + # Parse variations[N][field] pattern + indices: dict[int, dict[str, str]] = {} + for k, v in form.multi_items(): + m = _re.match(r"variations\[(\d+)\]\[(\w+)\]", k) + if m: + idx, field = int(m.group(1)), m.group(2) + indices.setdefault(idx, {})[field] = str(v) + + if not indices: + return HTMLResponse( + "

No variations provided.

", + status_code=400, + ) + + flag_type = flag.type + variations = [] + for i in sorted(indices.keys()): + entry = indices[i] + if entry.get("_deleted") == "1": + continue + name = entry.get("name", "").strip() + if not name: + return HTMLResponse( + f"

Variation {i} has no name.

", + status_code=400, + ) + raw_val = entry.get("value", "") + try: + parsed_val: bool | int | float | str | dict[str, Any] | list[Any] + if flag_type == FlagType.BOOLEAN: + parsed_val = raw_val.lower() in ("true", "1", "yes", "on") + elif flag_type == FlagType.INTEGER: + parsed_val = int(raw_val) + elif flag_type == FlagType.FLOAT: + parsed_val = float(raw_val) + elif flag_type == FlagType.JSON: + parsed_val = _json.loads(raw_val) if raw_val.strip() else {} + else: + parsed_val = raw_val + val = parsed_val + except Exception: + return HTMLResponse( + f"

Invalid value for variation '{name}'.

", + status_code=400, + ) + variations.append( + FlagVariation(name=name, value=val, description=entry.get("description", "") or "") + ) + + if len(variations) < 2: + return HTMLResponse( + "

At least two variations required.

", + status_code=400, + ) + + variation_names = {v.name for v in variations} + patch: dict[str, Any] = {"variations": variations} + # Fix off_variation if it no longer exists + if flag.off_variation not in variation_names: + patch["off_variation"] = variations[0].name + # Fix fallthrough if string and no longer valid + if isinstance(flag.fallthrough, str) and flag.fallthrough not in variation_names: + patch["fallthrough"] = variations[0].name + + updated = flag.model_copy(update=patch) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Variations saved
", + headers={"HX-Trigger": '{"flagVariationsSaved": true}'}, + ) + + +async def flag_targeting_save(request: Request) -> Response: + """POST /flags/{key}/targeting/save — update off_variation, fallthrough, and rules.""" + import re as _re + import uuid as _uuid + + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + variation_names = {v.name for v in flag.variations} + + patch: dict[str, Any] = {} + + # off_variation + off_var = str(form.get("off_variation", "")).strip() + if off_var: + if off_var not in variation_names: + return HTMLResponse( + f"

Unknown variation: {off_var}

", + status_code=400, + ) + patch["off_variation"] = off_var + + # fallthrough (only simple string form supported in dashboard) + fallthrough = str(form.get("fallthrough", "")).strip() + if fallthrough: + if fallthrough not in variation_names: + return HTMLResponse( + f"

Unknown variation: {fallthrough}

", + status_code=400, + ) + patch["fallthrough"] = fallthrough + + # rules — parse rules[N][field] and rules[N][clauses][M][field] + rule_data: dict[int, dict[str, Any]] = {} + for k, v in form.multi_items(): + m = _re.match(r"rules\[(\d+)\]\[clauses\]\[(\d+)\]\[(\w+)\]", k) + if m: + ri, ci, field = int(m.group(1)), int(m.group(2)), m.group(3) + rule_data.setdefault(ri, {}).setdefault("_clauses", {}).setdefault(ci, {})[field] = str( + v + ) + continue + m = _re.match(r"rules\[(\d+)\]\[(\w+)\]", k) + if m: + ri, field = int(m.group(1)), m.group(2) + rule_data.setdefault(ri, {})[field] = str(v) + + if rule_data: + from shield.core.feature_flags.models import Operator, RuleClause, TargetingRule + + rules = [] + for ri in sorted(rule_data.keys()): + rd = rule_data[ri] + if rd.get("_deleted") == "1": + continue + variation = rd.get("variation", "").strip() + if variation and variation not in variation_names: + return HTMLResponse( + f"

" + f"Rule {ri}: unknown variation '{variation}'

", + status_code=400, + ) + rule_id = rd.get("id", "").strip() or str(_uuid.uuid4()) + clauses = [] + for ci in sorted(rd.get("_clauses", {}).keys()): + cd = rd["_clauses"][ci] + if cd.get("_deleted") == "1": + continue + op_str = cd.get("operator", "is").strip() + try: + op = Operator(op_str) + except ValueError: + op = Operator.IS + # For segment operators the attribute field is hidden — default to "key" + is_seg_op = op in (Operator.IN_SEGMENT, Operator.NOT_IN_SEGMENT) + attr = cd.get("attribute", "").strip() or ("key" if is_seg_op else "") + if not attr: + continue + raw_values = cd.get("values", "") + values = [v.strip() for v in raw_values.split(",") if v.strip()] + negate = cd.get("negate", "false").lower() == "true" + clauses.append( + RuleClause(attribute=attr, operator=op, values=values, negate=negate) + ) + rules.append( + TargetingRule( + id=rule_id, + description=rd.get("description", "") or "", + clauses=clauses, + variation=variation or None, + ) + ) + patch["rules"] = rules + + if not patch: + return HTMLResponse( + "

Nothing to save.

", status_code=200 + ) + + updated = flag.model_copy(update=patch) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Targeting saved
", + headers={"HX-Trigger": '{"flagTargetingSaved": true}'}, + ) + + +async def flag_prerequisites_save(request: Request) -> Response: + """POST /flags/{key}/prerequisites/save — update flag prerequisites.""" + import re as _re + + from shield.core.feature_flags.models import Prerequisite + + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + prereq_data: dict[int, dict[str, str]] = {} + for k, v in form.multi_items(): + m = _re.match(r"prereqs\[(\d+)\]\[(\w+)\]", k) + if m: + idx, field = int(m.group(1)), m.group(2) + prereq_data.setdefault(idx, {})[field] = str(v) + + prereqs = [] + for i in sorted(prereq_data.keys()): + entry = prereq_data[i] + flag_key = entry.get("flag_key", "").strip() + variation = entry.get("variation", "").strip() + if not flag_key or not variation: + continue + if flag_key == key: + return HTMLResponse( + "

A flag cannot be its own prerequisite.

", + status_code=400, + ) + prereqs.append(Prerequisite(flag_key=flag_key, variation=variation)) + + updated = flag.model_copy(update={"prerequisites": prereqs}) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Prerequisites saved
", + headers={"HX-Trigger": '{"flagPrerequisitesSaved": true}'}, + ) + + +async def flag_targets_save(request: Request) -> Response: + """POST /flags/{key}/targets/save — update individual targets.""" + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + variation_names = {v.name for v in flag.variations} + targets: dict[str, list[str]] = {} + + for k, v in form.multi_items(): + if k.startswith("targets[") and k.endswith("]"): + variation_name = k[len("targets[") : -1] + if variation_name not in variation_names: + continue + keys = [line.strip() for line in str(v).splitlines() if line.strip()] + if keys: + targets[variation_name] = keys + + updated = flag.model_copy(update={"targets": targets}) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Targets saved
", + headers={"HX-Trigger": '{"flagTargetsSaved": true}'}, + ) + + +# --------------------------------------------------------------------------- +# Segment dashboard pages +# --------------------------------------------------------------------------- + + +async def segments_page(request: Request) -> Response: + """GET /segments — segment list page.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + segments = await engine.list_segments() + return tpl.TemplateResponse( + request, + "segments.html", + { + "prefix": prefix, + "segments": segments, + "active_tab": "segments", + "shield_actor": _actor(request), + "version": request.app.state.version, + "flags_enabled": True, + }, + ) + + +async def segments_rows_partial(request: Request) -> Response: + """GET /segments/rows — HTMX partial: segment table rows only.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + segments = await engine.list_segments() + q = request.query_params.get("q", "").lower().strip() + if q: + segments = [s for s in segments if q in s.key.lower() or q in (s.name or "").lower()] + return tpl.TemplateResponse( + request, + "partials/segment_rows.html", + {"prefix": prefix, "segments": segments}, + ) + + +async def modal_segment_view(request: Request) -> Response: + """GET /modal/segment/{key}/view — return segment info (read-only) modal.""" + tpl = _templates(request) + prefix = _prefix(request) + engine = _engine(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + return tpl.TemplateResponse( + request, + "partials/modal_segment_view.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) + + +async def modal_segment_detail(request: Request) -> Response: + """GET /modal/segment/{key} — return segment detail/edit modal.""" + tpl = _templates(request) + prefix = _prefix(request) + engine = _engine(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + return tpl.TemplateResponse( + request, + "partials/modal_segment_detail.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) + + +async def modal_segment_create(request: Request) -> Response: + """GET /modal/segment/create — return create segment modal.""" + tpl = _templates(request) + prefix = _prefix(request) + return tpl.TemplateResponse( + request, + "partials/modal_segment_create.html", + {"prefix": prefix}, + ) + + +async def segment_create_form(request: Request) -> Response: + """POST /segments/create — create segment from form; return new row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + form = await request.form() + key = str(form.get("key", "")).strip() + name = str(form.get("name", "")).strip() + if not key or not name: + return HTMLResponse( + "

Key and name are required.

", + status_code=400, + ) + from shield.core.feature_flags.models import Segment + + segment = Segment(key=key, name=name) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) + return tpl.TemplateResponse( + request, + "partials/segment_row.html", + {"prefix": prefix, "segment": segment}, + headers={"HX-Trigger": "segmentCreated"}, + ) + + +async def segment_delete(request: Request) -> Response: + """DELETE /segments/{key} — delete segment; return empty (HTMX removes row).""" + engine = _engine(request) + key = request.path_params["key"] + await engine.delete_segment(key, actor=_actor(request), platform=_platform(request)) + return HTMLResponse("") + + +async def segment_save_form(request: Request) -> Response: + """POST /segments/{key}/save — save segment edits from detail modal.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + if segment is None: + return HTMLResponse("

Segment not found.

", 404) + + form = await request.form() + # Parse included/excluded as newline-separated keys + included_raw = str(form.get("included", "")).strip() + excluded_raw = str(form.get("excluded", "")).strip() + included = [k.strip() for k in included_raw.splitlines() if k.strip()] + excluded = [k.strip() for k in excluded_raw.splitlines() if k.strip()] + segment = segment.model_copy(update={"included": included, "excluded": excluded}) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) + return tpl.TemplateResponse( + request, + "partials/segment_row.html", + {"prefix": prefix, "segment": segment}, + headers={"HX-Trigger": "segmentSaved"}, + ) + + +async def segment_rule_add(request: Request) -> Response: + """POST /segments/{key}/rules/add — add a targeting rule via the dashboard modal.""" + import uuid as _uuid + + from shield.core.feature_flags.models import Operator, RuleClause, SegmentRule + + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + if segment is None: + return HTMLResponse("

Segment not found.

", 404) + + form = await request.form() + description = str(form.get("description", "")).strip() + attribute = str(form.get("attribute", "")).strip() + operator_str = str(form.get("operator", "is")).strip() + values_raw = str(form.get("values", "")).strip() + negate = bool(form.get("negate")) + + # For segment operators the attribute is implicitly "key" + is_seg_op = operator_str in ("in_segment", "not_in_segment") + if is_seg_op: + attribute = "key" + + if not attribute or not values_raw: + return HTMLResponse( + "

Attribute and values are required.

", + status_code=400, + ) + + try: + op = Operator(operator_str) + except ValueError: + return HTMLResponse( + f"

Unknown operator: {operator_str}

", + status_code=400, + ) + + values: list[str] = [v.strip() for v in values_raw.split(",") if v.strip()] + clause = RuleClause(attribute=attribute, operator=op, values=values, negate=negate) + rule = SegmentRule(id=str(_uuid.uuid4()), description=description, clauses=[clause]) + + rules = list(segment.rules) + [rule] + segment = segment.model_copy(update={"rules": rules}) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) + return tpl.TemplateResponse( + request, + "partials/segment_rules_section.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) + + +async def segment_rule_delete(request: Request) -> Response: + """DELETE /segments/{key}/rules/{rule_id} — remove a targeting rule.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + rule_id = request.path_params["rule_id"] + segment = await engine.get_segment(key) + if segment is None: + return HTMLResponse("

Segment not found.

", 404) + + rules = [r for r in segment.rules if r.id != rule_id] + segment = segment.model_copy(update={"rules": rules}) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) + return tpl.TemplateResponse( + request, + "partials/segment_rules_section.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) diff --git a/shield/dashboard/static/shield.min.css b/shield/dashboard/static/shield.min.css index d2f527b..67008a2 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-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--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-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--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-green-50:oklch(98.2% .018 155.826);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--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-100:oklch(93% .034 272.788);--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-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--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-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--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-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--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-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--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-tight:1.25;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--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;--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}.inset-0{inset:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.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-16{bottom:calc(var(--spacing) * 16)}.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-4{margin-inline:calc(var(--spacing) * -4)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.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-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.-mr-1{margin-right:calc(var(--spacing) * -1)}.-mb-px{margin-bottom:-1px}.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-14{height:calc(var(--spacing) * 14)}.h-\[1\.45rem\]{height:1.45rem}.h-\[1\.85rem\]{height:1.85rem}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.h-screen{height:100vh}.min-h-0{min-height:calc(var(--spacing) * 0)}.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-16{width:calc(var(--spacing) * 16)}.w-28{width:calc(var(--spacing) * 28)}.w-60{width:calc(var(--spacing) * 60)}.w-fit{width:fit-content}.w-full{width:100%}.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-\[8rem\]{min-width:8rem}.min-w-\[380px\]{min-width:380px}.min-w-\[500px\]{min-width:500px}.min-w-\[520px\]{min-width:520px}.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}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.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}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,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}.items-stretch{align-items:stretch}.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-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}: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)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * 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}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.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-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.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-100{border-color:var(--color-emerald-100)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-orange-100{border-color:var(--color-orange-100)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-300{border-color:var(--color-orange-300)}.border-red-100{border-color:var(--color-red-100)}.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-red-800{border-color:var(--color-red-800)}.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-slate-700{border-color:var(--color-slate-700)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.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-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.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-green-50{background-color:var(--color-green-50)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-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-red-700{background-color:var(--color-red-700)}.bg-red-950{background-color:var(--color-red-950)}.bg-red-950\/60{background-color:#46080999}@supports (color:color-mix(in lab, red, red)){.bg-red-950\/60{background-color:color-mix(in oklab, var(--color-red-950) 60%, transparent)}}.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\/50{background-color:#f8fafc80}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/50{background-color:color-mix(in oklab, var(--color-slate-50) 50%, transparent)}}.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-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-500{background-color:var(--color-violet-500)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-violet-700{background-color:var(--color-violet-700)}.bg-white{background-color:var(--color-white)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0{padding-inline:calc(var(--spacing) * 0)}.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-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.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)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.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-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--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-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.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)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.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-500{color:var(--color-emerald-500)}.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-green-500{color:var(--color-green-500)}.text-green-700{color:var(--color-green-700)}.text-indigo-400{color:var(--color-indigo-400)}.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-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-red-200{color:var(--color-red-200)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.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-400{color:var(--color-violet-400)}.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-0{opacity:0}.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-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-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-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-orange-300\/40{--tw-ring-color:#ffb96d66}@supports (color:color-mix(in lab, red, red)){.ring-orange-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-orange-300) 40%, transparent)}}.ring-orange-600\/20{--tw-ring-color:#f0510033}@supports (color:color-mix(in lab, red, red)){.ring-orange-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-orange-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-200{--tw-ring-color:var(--color-slate-200)}.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-200{--tw-ring-color:var(--color-violet-200)}.ring-violet-300\/40{--tw-ring-color:#c4b4ff66}@supports (color:color-mix(in lab, red, red)){.ring-violet-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-violet-300) 40%, 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)}}.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-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))}.transition-transform{transition-property:transform,translate,scale,rotate;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}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}.group-open\/add\:rotate-45:is(:where(.group\/add):is([open],:popover-open,:open) *){rotate:45deg}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.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}.focus-within\:border-violet-500:focus-within{border-color:var(--color-violet-500)}.focus-within\:ring-1:focus-within{--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)}.focus-within\:ring-violet-500:focus-within{--tw-ring-color:var(--color-violet-500)}@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-orange-200:hover{border-color:var(--color-orange-200)}.hover\:border-orange-400:hover{border-color:var(--color-orange-400)}.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\:border-violet-200:hover{border-color:var(--color-violet-200)}.hover\:border-violet-300:hover{border-color:var(--color-violet-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-orange-50:hover{background-color:var(--color-orange-50)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-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-600:hover{background-color:var(--color-red-600)}.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-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-600:hover{background-color:var(--color-violet-600)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-orange-700:hover{color:var(--color-orange-700)}.hover\:text-red-300:hover{color:var(--color-red-300)}.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-600:hover{color:var(--color-slate-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-slate-900:hover{color:var(--color-slate-900)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-violet-700:hover{color:var(--color-violet-700)}.hover\:text-violet-800:hover{color:var(--color-violet-800)}.hover\:text-violet-900:hover{color:var(--color-violet-900)}.hover\:underline:hover{text-decoration-line:underline}.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\:border-violet-400:focus{border-color:var(--color-violet-400)}.focus\:border-violet-500:focus{border-color:var(--color-violet-500)}.focus\:ring-1:focus{--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)}.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-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-orange-500:focus{--tw-ring-color:var(--color-orange-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-red-700:focus{--tw-ring-color:var(--color-red-700)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-300:focus{--tw-ring-color:var(--color-slate-300)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-400:focus{--tw-ring-color:var(--color-violet-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-violet-600:focus{--tw-ring-color:var(--color-violet-600)}.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\:ring-offset-white:focus{--tw-ring-offset-color:var(--color-white)}.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-violet-800:active{background-color:var(--color-violet-800)}@media (min-width:480px){.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\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:flex-1{flex:1}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.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\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}}@media (min-width:48rem){.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:-mx-8{margin-inline:calc(var(--spacing) * -8)}.lg\:ml-auto{margin-left:auto}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-64{width:calc(var(--spacing) * 64)}.lg\:shrink-0{flex-shrink:0}.lg\:translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:pl-60{padding-left:calc(var(--spacing) * 60)}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@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 #} + + + + + + + + + + + + + + + + api-shield + +
- - - -
- {% block content %}{% endblock %} -
- - - - - - - - -
- - - - + + +
+ {% block content %}{% endblock %} +
+
+
- + // URL-pattern success toasts for legacy route actions. + const url = (e.detail.xhr && e.detail.xhr.responseURL) || cfg.path || ''; + if (!url) return; + const msg = getMessage(url, verb); + if (msg) showToast(msg); + }); + })(); + + function applyService(svc) { + const url = new URL(window.location); + if (svc) url.searchParams.set('service', svc); + else url.searchParams.delete('service'); + window.location = url.toString(); + } + + // ── Mobile sidebar toggle ──────────────────────────────────── + function toggleSidebar() { + const s = document.getElementById('app-sidebar'); + const b = document.getElementById('sidebar-backdrop'); + const open = !s.classList.contains('-translate-x-full'); + s.classList.toggle('-translate-x-full', open); + s.classList.toggle('translate-x-0', !open); + b.classList.toggle('hidden', open); + } + + // ── Delete confirmation modal ──────────────────────────────── + function shieldConfirmDelete(opts) { + // opts: { label, key, url, targetId } + const _m = document.getElementById('shield-modal'); + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + const safeKey = esc(opts.key || ''); + const safeUrl = esc(opts.url || ''); + const safeId = esc(opts.targetId || ''); + _m.innerHTML = ` +
+
+
+ + + +
+
+

Delete ${esc(opts.label || 'Item')}

+

This action cannot be undone.

+
+
+
+

+ Permanently delete + ${safeKey}? +

+
+
+ + +
+
`; + _m.showModal(); + } + function shieldExecDelete(url, targetId) { + htmx.ajax('DELETE', url, {target: '#' + targetId, swap: 'outerHTML'}); + document.getElementById('shield-modal').close(); + } + // Close
dropdowns when clicking outside + document.addEventListener('click', function(e) { + document.querySelectorAll('details[data-dropdown]').forEach(d => { + if (!d.contains(e.target)) d.removeAttribute('open'); + }); + }); + function shieldConfirmToggle(opts) { + const _m = document.getElementById('shield-modal'); + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + const isEnable = opts.action === 'enable'; + const color = isEnable ? 'emerald' : 'amber'; + const label = isEnable ? 'Enable' : 'Disable'; + const bodyText = isEnable + ? 'will be enabled and start serving its on variation to users.' + : 'will be disabled and serve the off variation to all users.'; + const iconSvg = isEnable + ? '' + : ''; + const iconBgClass = isEnable ? 'shield-icon-enable' : 'shield-icon-disable'; + const btnClass = isEnable ? 'shield-btn-enable' : 'shield-btn-disable'; + _m.innerHTML = ` +
+
+
+ ${iconSvg} +
+
+

${label} flag?

+

+ Flag ${esc(opts.key)} ${bodyText} +

+
+
+
+ + +
+
`; + _m.showModal(); + } + function shieldExecToggle(url, targetId, swap) { + htmx.ajax('POST', url, {target: '#' + targetId, swap: swap}); + document.getElementById('shield-modal').close(); + } + diff --git a/shield/dashboard/templates/flag_detail.html b/shield/dashboard/templates/flag_detail.html new file mode 100644 index 0000000..5335c1c --- /dev/null +++ b/shield/dashboard/templates/flag_detail.html @@ -0,0 +1,773 @@ +{% extends "base.html" %} + +{% block content %} + +{% set _colour = flag_type_colours.get(flag.type.value, 'slate') %} + +{# ── Breadcrumb ────────────────────────────────────────────────────── #} +
+ + + + + Flags + +
+ +{# ── Header ────────────────────────────────────────────────────────── #} +
+

{{ flag.key }}

+ + {{ flag.type.value }} + + + {% if flag.enabled %} + + Enabled + + {% else %} + + Disabled + + {% endif %} + +
+ +{# ── Tabs ──────────────────────────────────────────────────────────── #} +
+
+ + + {# Overview #} + + + {# Targeting #} + + + {# Variations #} + + + {# Settings #} + +
+
+ +
+ + {# ── Overview panel ────────────────────────────────────────────── #} +
+
+ {# Details card #} +
+

Details

+
+
+
Type
+
+ + {{ flag.type.value }} + +
+
+
+
Status
+
+ {{ 'Enabled' if flag.enabled else 'Disabled' }} +
+
+
+
Off Variation
+
{{ flag.off_variation or '—' }}
+
+
+
Fallthrough
+
+ {% if flag.fallthrough is string %}{{ flag.fallthrough }} + {% elif flag.fallthrough is iterable %}rollout + {% else %}—{% endif %} +
+
+ {% if flag.description %} +
+
Description
+
{{ flag.description }}
+
+ {% endif %} +
+
+ + {# Counts card #} +
+

Counts

+
+
+
Rules
+
{{ flag.rules | length }}
+
+
+
Variations
+
{{ flag.variations | length }}
+
+
+
Prerequisites
+
{{ flag.prerequisites | length }}
+
+
+
Individual Targets
+
{{ flag.targets | length }}
+
+
+
+
+ + {# Variations summary table #} +
+
+

Variations

+
+ {% if flag.variations %} +
+ + + + + + + + + + {% for v in flag.variations %} + + + + + + {% endfor %} + +
NameValueDescription
{{ v.name }}{{ v.value }}{{ v.description or '—' }}
+
+ {% else %} +

No variations defined.

+ {% endif %} +
+
+ + {# ── Targeting panel ───────────────────────────────────────────── #} + + + {# ── Variations panel ──────────────────────────────────────────── #} + + + {# ── Settings panel ────────────────────────────────────────────── #} + + + +
+ + + +{% endblock %} diff --git a/shield/dashboard/templates/flags.html b/shield/dashboard/templates/flags.html new file mode 100644 index 0000000..70b8026 --- /dev/null +++ b/shield/dashboard/templates/flags.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} + +{% block content %} + +{# ── Page header ───────────────────────────────────────────────────── #} +
+
+

Feature Flags

+

Manage and roll out features to your users

+
+ +
+ +{# ── Stats bar — always visible ────────────────────────────────────── #} +{% set total = flags | length %} +{% set enabled_count = flags | selectattr('enabled') | list | length %} +{% set disabled_count = total - enabled_count %} +
+
+ +
+
{{ total }}
+
Total
+
+
+
+ +
+
{{ enabled_count }}
+
Enabled
+
+
+
+ +
+
{{ disabled_count }}
+
Disabled
+
+
+
+ +{# ── Filters — only show if flags exist ────────────────────────────── #} +{% if flags %} +
+ {# Search — left on sm+ #} +
+ + + + +
+ {# Filters — right on sm+ #} +
+ + +
+
+{% endif %} + +{# ── Flags table — ALWAYS rendered so #flag-table-body always exists ─ #} +
+
+ + + + + + + + + + + + + {% include "partials/flag_rows.html" with context %} + +
KeyTypeStatusActions
+
+
+ +{% endblock %} diff --git a/shield/dashboard/templates/index.html b/shield/dashboard/templates/index.html index e8acf29..299a66a 100644 --- a/shield/dashboard/templates/index.html +++ b/shield/dashboard/templates/index.html @@ -2,9 +2,39 @@ {% block content %} -{# ── Global maintenance control ───────────────────────────────────── #} -{% set config = global_config %} -{% include "partials/global_maintenance.html" with context %} +{# ── Maintenance controls ──────────────────────────────────────────── #} +{# + Multi-service (service selected): + Global (left) + Service (right) in one responsive row. + Stacks to a single column on mobile. + + Embedded / All services: + Global maintenance takes the full width as before. +#} +{% if selected_service and service_config is not none %} + +
+
+ {# Left — all-services global maintenance #} + {% set config = global_config %} + {% include "partials/global_maintenance.html" with context %} +
+
+ {# Right — per-service maintenance #} + {% set config = service_config %} + {% set service = selected_service %} + {% include "partials/service_maintenance.html" with context %} +
+
+ +{% else %} + +
+ {% set config = global_config %} + {% include "partials/global_maintenance.html" with context %} +
+ +{% endif %} {# ── Compute per-status counts for the summary bar ────────────────── #} {% set n_active = states | selectattr("status", "equalto", "active") | list | length %} @@ -20,10 +50,13 @@

Routes

{{ states | length }} route{{ 's' if states | length != 1 else '' }} registered {% if n_maintenance > 0 %} - · {{ n_maintenance }} in maintenance + · {{ n_maintenance }} in maintenance {% endif %} {% if n_disabled > 0 %} - · {{ n_disabled }} disabled + · {{ n_disabled }} disabled + {% endif %} + {% if selected_service %} + · Showing routes for {{ selected_service }} {% endif %}

@@ -41,41 +74,41 @@

Routes

{# ── Status summary cards ─────────────────────────────────────────── #}
-
+
{{ n_active }}
Active
-
-
{{ n_maintenance }}
+
+
{{ n_maintenance }}
Maintenance
-
-
{{ n_disabled }}
+
+
{{ n_disabled }}
Disabled
-
-
{{ n_env_gated }}
+
+
{{ n_env_gated }}
Env Gated
-
+
{{ n_deprecated }}
Deprecated
{# ── Routes table ─────────────────────────────────────────────────── #} -
+
+ sse-connect="{{ prefix }}/events{% if selected_service %}?service={{ selected_service }}{% endif %}"> - - - - - - + + + + + + @@ -93,15 +126,15 @@

Routes

{% else %} {# ── Empty state ──────────────────────────────────────────────────── #} -
+
-
+

No routes registered

-

Routes appear here once the application starts with ShieldRouter or ShieldMiddleware.

+

Routes appear here once the application starts with ShieldRouter or ShieldMiddleware.

diff --git a/shield/dashboard/templates/login.html b/shield/dashboard/templates/login.html index abb9fa7..20bfe41 100644 --- a/shield/dashboard/templates/login.html +++ b/shield/dashboard/templates/login.html @@ -4,16 +4,18 @@ Shield — Sign in + + + - +
- - + @@ -33,8 +35,8 @@

api-shield

{% if error %} -
- +
+ api-shield {% endif %} -
+
-
-
+ + {# ── Key ─────────────────────────────────────────────────────────── #} + + + {# ── Type badge ───────────────────────────────────────────────────── #} + + + {# ── Status ───────────────────────────────────────────────────────── #} + + + {# ── Variations ───────────────────────────────────────────────────── #} + + + {# ── Fallthrough ──────────────────────────────────────────────────── #} + + + {# ── Actions ──────────────────────────────────────────────────────── #} + + diff --git a/shield/dashboard/templates/partials/flag_rows.html b/shield/dashboard/templates/partials/flag_rows.html new file mode 100644 index 0000000..5e96931 --- /dev/null +++ b/shield/dashboard/templates/partials/flag_rows.html @@ -0,0 +1,34 @@ +{% if flags %} + {% for flag in flags %} + {% include "partials/flag_row.html" with context %} + {% endfor %} +{% else %} + + + +{% endif %} diff --git a/shield/dashboard/templates/partials/global_maintenance.html b/shield/dashboard/templates/partials/global_maintenance.html index 64696f0..3d311ed 100644 --- a/shield/dashboard/templates/partials/global_maintenance.html +++ b/shield/dashboard/templates/partials/global_maintenance.html @@ -1,8 +1,8 @@ -
+
{% if config.enabled %} {# ── Active: prominent warning banner ─────────────────────────── #} -
+
@@ -50,7 +50,7 @@ {% else %} {# ── Inactive: subtle control bar ─────────────────────────────── #} -
+
diff --git a/shield/dashboard/templates/partials/global_rl_card.html b/shield/dashboard/templates/partials/global_rl_card.html index c179af5..8ce82d7 100644 --- a/shield/dashboard/templates/partials/global_rl_card.html +++ b/shield/dashboard/templates/partials/global_rl_card.html @@ -1,10 +1,10 @@ -
+
{% if grl %} {# ── Configured: info bar with details (active or paused) ─────── #} {% set is_active = grl.enabled %} -
+
@@ -109,7 +109,7 @@ {% else %} {# ── Not configured: subtle control bar ───────────────────────── #} -
+

Global rate limit is not configured

diff --git a/shield/dashboard/templates/partials/modal_flag_create.html b/shield/dashboard/templates/partials/modal_flag_create.html new file mode 100644 index 0000000..e5400fd --- /dev/null +++ b/shield/dashboard/templates/partials/modal_flag_create.html @@ -0,0 +1,105 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Create Feature Flag

+

New flags start disabled — enable when ready to roll out.

+
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} + + +
+ {# Key #} +
+ + +

Lowercase letters, digits, hyphens, and underscores.

+
+ + {# Name #} +
+ + +
+ + {# Type #} +
+ + +
+
+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_flag_eval.html b/shield/dashboard/templates/partials/modal_flag_eval.html new file mode 100644 index 0000000..442f096 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_flag_eval.html @@ -0,0 +1,112 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Evaluate Flag

+ + {% if flag %}{{ flag.key }}{% else %}{{ key }}{% endif %} + +
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} +
+ +
+ {# Context Key + Kind (side by side on sm) #} +
+
+ + +
+
+ + + + +
+
+ + {# Attributes #} +
+ + +

One key=value pair per line.

+
+
+ +
+ + +
+ + +
+ +
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/modal_segment_create.html b/shield/dashboard/templates/partials/modal_segment_create.html new file mode 100644 index 0000000..0283249 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_segment_create.html @@ -0,0 +1,86 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Create Segment

+

Group users to use in flag targeting rules.

+
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} + + +
+ {# Key #} +
+ + +

Lowercase letters, digits, hyphens, and underscores.

+
+ + {# Name #} +
+ + +
+
+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_segment_detail.html b/shield/dashboard/templates/partials/modal_segment_detail.html new file mode 100644 index 0000000..113086e --- /dev/null +++ b/shield/dashboard/templates/partials/modal_segment_detail.html @@ -0,0 +1,118 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Edit Segment

+ {{ segment.key }} +
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} +
+ +
+ {# Key + Name (readonly, compact) #} +
+
+ +
+ {{ segment.key }} +
+
+
+ +
+ {{ segment.name }} +
+
+
+ + {# Included #} +
+
+ + {% set inc_count = segment.included | length %} + {% if inc_count > 0 %} + {{ inc_count }} user{{ 's' if inc_count != 1 else '' }} + {% endif %} +
+ +

One context key per line. Users in this list are always included.

+
+ + {# Excluded #} +
+
+ + {% set exc_count = segment.excluded | length %} + {% if exc_count > 0 %} + {{ exc_count }} user{{ 's' if exc_count != 1 else '' }} + {% endif %} +
+ +

One context key per line. Users in this list are always excluded.

+
+ +
+ + {# Rules section — managed independently via HTMX #} +
+ {% include "partials/segment_rules_section.html" with context %} +
+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_segment_view.html b/shield/dashboard/templates/partials/modal_segment_view.html new file mode 100644 index 0000000..a799b3a --- /dev/null +++ b/shield/dashboard/templates/partials/modal_segment_view.html @@ -0,0 +1,151 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

{{ segment.name or segment.key }}

+ {{ segment.key }} +
+ +
+ + {# ── Stats row ────────────────────────────────────────────────────── #} +
+ + {{ segment.included | length }} + Included + + · + + {{ segment.excluded | length }} + Excluded + + · + + {{ segment.rules | length }} + Rules + +
+ +
+ + {# ── Included ─────────────────────────────────────────────────── #} +
+

+ Included + — always in segment +

+ {% if segment.included %} +
+
+ {% for k in segment.included %} + {{ k }} + {% endfor %} +
+
+ {% else %} +

No explicit inclusions.

+ {% endif %} +
+ + {# ── Excluded ─────────────────────────────────────────────────── #} +
+

+ Excluded + — always out of segment +

+ {% if segment.excluded %} +
+
+ {% for k in segment.excluded %} + {{ k }} + {% endfor %} +
+
+ {% else %} +

No explicit exclusions.

+ {% endif %} +
+ + {# ── Rules ────────────────────────────────────────────────────── #} +
+

+ Targeting Rules + — OR logic between rules, AND within clauses +

+ {% if segment.rules %} +
+ {% for rule in segment.rules %} +
+
+ Rule {{ loop.index }} + {{ rule.id[:8] }}… +
+ {% if rule.clauses %} +
+ {% for clause in rule.clauses %} +
+ {% if not loop.first %} + AND + {% else %} + + {% endif %} + {{ clause.attribute }} + {{ clause.operator.value }} + + {% for v in clause.values %} + {{ v }} + {% endfor %} + +
+ {% endfor %} +
+ {% else %} +

No clauses.

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No targeting rules defined.

+ {% endif %} +
+ +
+ + {# ── Footer ───────────────────────────────────────────────────────── #} +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_service_disable.html b/shield/dashboard/templates/partials/modal_service_disable.html new file mode 100644 index 0000000..d42bb72 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_service_disable.html @@ -0,0 +1,51 @@ +
+ + {# ── Header ───────────────────────────────────────────────── #} +
+
+ + + +
+
+

+ Restore {{ service }} +

+

+ Disable service maintenance and return routes to their individual states. +

+
+
+ +
+ + + +

+ Routes for {{ service }} will + resume following their individual lifecycle states (active, maintenance, disabled, etc.). +

+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_service_enable.html b/shield/dashboard/templates/partials/modal_service_enable.html new file mode 100644 index 0000000..7ee9192 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_service_enable.html @@ -0,0 +1,95 @@ +
+ + {# ── Header ───────────────────────────────────────────────── #} +
+
+ + + +
+
+

+ Enable Maintenance — {{ service }} +

+

+ All routes for this service return 503. Other services are unaffected. +

+
+
+ + {# ── Warning banner ───────────────────────────────────────── #} +
+ + + +

+ All routes for {{ service }} will return 503. + Other services continue operating normally. Add exempt paths below to keep critical routes reachable. +

+
+ + {# ── Form ─────────────────────────────────────────────────── #} +
+ + + + {# Reason #} +
+ + +
+ + {# Exempt paths #} +
+ + +
+ + {# Buttons #} +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_service_rl.html b/shield/dashboard/templates/partials/modal_service_rl.html new file mode 100644 index 0000000..73d58dc --- /dev/null +++ b/shield/dashboard/templates/partials/modal_service_rl.html @@ -0,0 +1,131 @@ +
+ + {# ── Header ─────────────────────────────────────────────────── #} +
+
+ + + +
+
+

{% if srl %}Edit{% else %}Configure{% endif %} {{ service }} Rate Limit

+

Applies to all {{ service }} routes except those listed as exempt.

+
+
+ + {# ── Edit form ───────────────────────────────────────────────── #} +
+ + + + {# Limit #} +
+ + +
+ + {# Algorithm + Key Strategy side by side #} +
+
+ + +
+
+ + +
+
+ + {# Burst #} +
+ + +

Extra requests allowed above the base limit.

+
+ + {# Exempt Routes #} +
+ + +

One route per line. Use /path for all methods or GET:/path for a specific method.

+
+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_service_rl_delete.html b/shield/dashboard/templates/partials/modal_service_rl_delete.html new file mode 100644 index 0000000..7f7febe --- /dev/null +++ b/shield/dashboard/templates/partials/modal_service_rl_delete.html @@ -0,0 +1,41 @@ +
+ + {# ── Header ─────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Remove {{ service }} Rate Limit

+

All {{ service }} routes will be unrestricted by the service limit. Per-route policies are unaffected.

+
+
+ + {# ── Confirmation form ───────────────────────────────────────── #} +
+ + + +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_service_rl_reset.html b/shield/dashboard/templates/partials/modal_service_rl_reset.html new file mode 100644 index 0000000..2a48844 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_service_rl_reset.html @@ -0,0 +1,41 @@ +
+ + {# ── Header ─────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Reset {{ service }} Rate Limit Counters

+

Clears all accumulated counts for {{ service }}. The policy is kept — only the counters are cleared.

+
+
+ + {# ── Confirmation form ───────────────────────────────────────── #} +
+ + + +
+ + +
+ +
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 ──────────────────────────────────────────────────────── #}
+ + {# ── Key ─────────────────────────────────────────────────────────── #} + + + {# ── Name ─────────────────────────────────────────────────────────── #} + + + {# ── Included ─────────────────────────────────────────────────────── #} + + + {# ── Excluded ─────────────────────────────────────────────────────── #} + + + {# ── Rules ─────────────────────────────────────────────────────────── #} + + + {# ── Actions ──────────────────────────────────────────────────────── #} + + diff --git a/shield/dashboard/templates/partials/segment_rows.html b/shield/dashboard/templates/partials/segment_rows.html new file mode 100644 index 0000000..3efe641 --- /dev/null +++ b/shield/dashboard/templates/partials/segment_rows.html @@ -0,0 +1,34 @@ +{% if segments %} + {% for segment in segments %} + {% include "partials/segment_row.html" with context %} + {% endfor %} +{% else %} + + + +{% endif %} diff --git a/shield/dashboard/templates/partials/segment_rules_section.html b/shield/dashboard/templates/partials/segment_rules_section.html new file mode 100644 index 0000000..a08c730 --- /dev/null +++ b/shield/dashboard/templates/partials/segment_rules_section.html @@ -0,0 +1,172 @@ +{# ── Segment Rules Section ───────────────────────────────────────────────── + Rendered inside modal_segment_detail.html and returned by HTMX add/remove + rule endpoints. Expects: segment, key, prefix. +─────────────────────────────────────────────────────────────────────────── #} +
+ + {# ── Label ──────────────────────────────────────────────────────────── #} +
+ +
+

+ Users matching any rule are included in this segment. + Rules are evaluated after the explicit included / excluded lists. +

+ + {# ── Existing rules ──────────────────────────────────────────────────── #} + {% if segment.rules %} +
+ {% for rule in segment.rules %} +
+
+ {% if rule.description %} +

{{ rule.description }}

+ {% endif %} + {% for clause in rule.clauses %} + {% set is_seg_op = clause.operator.value in ['in_segment', 'not_in_segment'] %} +
+ {% if not loop.first %} + AND + {% endif %} + {% if clause.negate %} + NOT + {% endif %} + {% if not is_seg_op %} + {{ clause.attribute }} + {% endif %} + {{ clause.operator.value.replace('_', ' ') }} + {{ clause.values | join(', ') }} +
+ {% endfor %} +
+ +
+ {% endfor %} +
+ {% else %} +

No targeting rules yet.

+ {% endif %} + + {# ── Add Rule form ───────────────────────────────────────────────────── #} +
+ + + + + Add Rule + + +
+ + {# Description (optional) #} +
+ + +
+ + {# Clause: attribute + operator + values #} +
+ +
+ {# Attribute #} + + + {# Operator #} + + + {# Values #} + +
+

Separate multiple values with commas.

+
+ + {# Negate #} + + +
+ + +
+ +
+
+ + diff --git a/shield/dashboard/templates/partials/service_maintenance.html b/shield/dashboard/templates/partials/service_maintenance.html new file mode 100644 index 0000000..7ed0d47 --- /dev/null +++ b/shield/dashboard/templates/partials/service_maintenance.html @@ -0,0 +1,75 @@ +
+{% if config and config.enabled %} + + {# ── Active: prominent warning banner ─────────────────────────── #} +
+
+ + + +
+
+
+ Service Maintenance Active + {{ service }} + + + All routes → 503 + +
+ {% if config.reason %} +

{{ config.reason }}

+ {% endif %} + {% if config.exempt_paths %} +

+ Exempt: + {{ config.exempt_paths | join(", ") }} +

+ {% endif %} +
+ +
+ +{% else %} + + {# ── Inactive: subtle control bar ─────────────────────────────── #} +
+
+
+ +
+
+

+ {{ service }} maintenance is off +

+ +
+
+ +
+ +{% endif %} +
diff --git a/shield/dashboard/templates/partials/service_rl_card.html b/shield/dashboard/templates/partials/service_rl_card.html new file mode 100644 index 0000000..e0ff5ab --- /dev/null +++ b/shield/dashboard/templates/partials/service_rl_card.html @@ -0,0 +1,135 @@ +
+ +{% if srl %} + + {# ── Configured: info bar with details (active or paused) ─────── #} + {% set is_active = srl.enabled %} +
+
+ + + +
+
+
+ {{ service }} Rate Limit + {% if is_active %} + + + Active + + {% else %} + + + Paused + + {% endif %} +
+
+

{{ srl.limit }}

+

{{ srl.algorithm }} · {{ srl.key_strategy }}{% if srl.burst %} · +{{ srl.burst }} burst{% endif %}

+
+ {% if srl.exempt_routes %} +
+ Exempt: + {% for route in srl.exempt_routes %} + {{ route }} + {% endfor %} +
+ {% endif %} +
+
+ {# Enable / Disable toggle #} + {% if is_active %} + + {% else %} + + {% endif %} + + + +
+
+ +{% else %} + + {# ── Not configured: subtle control bar ───────────────────────── #} +
+
+

{{ service }} rate limit is not configured

+ +
+ +
+ +{% endif %} +
diff --git a/shield/dashboard/templates/rate_limits.html b/shield/dashboard/templates/rate_limits.html index 0323575..00891dc 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.

- + @@ -15,10 +15,41 @@

Rate Limits

-{# ── Global rate limit card ──────────────────────────────────────── #} -{% with grl=global_rl %} -{% include "partials/global_rl_card.html" %} -{% endwith %} +{# ── Rate limit cards ─────────────────────────────────────────────── #} +{# + Multi-service (service selected): + Global (left) + Service (right) in one responsive row. + Stacks to a single column on mobile. + + No service selected: + Global rate limit takes the full width. +#} +{% if selected_service %} + +
+
+ {# Left — all-services global rate limit #} + {% with grl=global_rl %} + {% include "partials/global_rl_card.html" %} + {% endwith %} +
+
+ {# Right — per-service rate limit #} + {% with srl=service_rl, service=selected_service %} + {% include "partials/service_rl_card.html" %} + {% endwith %} +
+
+ +{% else %} + +
+ {% with grl=global_rl %} + {% include "partials/global_rl_card.html" %} + {% endwith %} +
+ +{% endif %} {# ── Policies table ─────────────────────────────────────────────── #}
@@ -50,9 +81,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 +
+
+
PathStatusActions
PathStatusActions
- {{ entry.path }} + {{ entry | clean_entry_path }} + {% if entry.service %} + {{ entry.service }} + {% endif %} @@ -47,7 +50,15 @@ "global_rl_reset": "bg-sky-50 text-sky-700 ring-sky-600/20", "global_rl_deleted": "bg-red-50 text-red-700 ring-red-600/20", "global_rl_enabled": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", - "global_rl_disabled":"bg-slate-100 text-slate-500 ring-slate-400/20" + "global_rl_disabled":"bg-slate-100 text-slate-500 ring-slate-400/20", + "flag_created": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "flag_updated": "bg-amber-50 text-amber-700 ring-amber-600/20", + "flag_enabled": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "flag_disabled": "bg-slate-100 text-slate-500 ring-slate-400/20", + "flag_deleted": "bg-red-50 text-red-700 ring-red-600/20", + "segment_created": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "segment_updated": "bg-amber-50 text-amber-700 ring-amber-600/20", + "segment_deleted": "bg-red-50 text-red-700 ring-red-600/20" } %} {% set rl_action_labels = { "rl_policy_set": "set", @@ -59,7 +70,15 @@ "global_rl_reset": "global reset", "global_rl_deleted": "global delete", "global_rl_enabled": "global enabled", - "global_rl_disabled":"global disabled" + "global_rl_disabled":"global disabled", + "flag_created": "created", + "flag_updated": "updated", + "flag_enabled": "enabled", + "flag_disabled": "disabled", + "flag_deleted": "deleted", + "segment_created": "created", + "segment_updated": "updated", + "segment_deleted": "deleted" } %} {% if entry.action in rl_action_labels %} diff --git a/shield/dashboard/templates/partials/flag_eval_result.html b/shield/dashboard/templates/partials/flag_eval_result.html new file mode 100644 index 0000000..040891b --- /dev/null +++ b/shield/dashboard/templates/partials/flag_eval_result.html @@ -0,0 +1,137 @@ +{% set _reason = result.reason.value %} + +{# ── Reason metadata ──────────────────────────────────────────────── #} +{% if _reason == 'RULE_MATCH' %} + {% set _badge = 'bg-indigo-50 text-indigo-700 ring-indigo-600/20' %} + {% set _label = 'Rule matched' %} + {% set _icon_colour = 'text-indigo-500' %} +{% elif _reason == 'TARGET_MATCH' %} + {% set _badge = 'bg-green-50 text-green-700 ring-green-600/20' %} + {% set _label = 'Individual target' %} + {% set _icon_colour = 'text-green-500' %} +{% elif _reason == 'FALLTHROUGH' %} + {% set _badge = 'bg-blue-50 text-blue-700 ring-blue-600/20' %} + {% set _label = 'Default rule (fallthrough)' %} + {% set _icon_colour = 'text-blue-500' %} +{% elif _reason == 'OFF' %} + {% set _badge = 'bg-slate-100 text-slate-600 ring-slate-400/20' %} + {% set _label = 'Flag disabled' %} + {% set _icon_colour = 'text-slate-400' %} +{% elif _reason == 'PREREQUISITE_FAIL' %} + {% set _badge = 'bg-orange-50 text-orange-700 ring-orange-600/20' %} + {% set _label = 'Prerequisite failed' %} + {% set _icon_colour = 'text-orange-500' %} +{% elif _reason == 'ERROR' %} + {% set _badge = 'bg-red-50 text-red-700 ring-red-600/20' %} + {% set _label = 'Evaluation error' %} + {% set _icon_colour = 'text-red-500' %} +{% else %} + {% set _badge = 'bg-slate-100 text-slate-600 ring-slate-400/20' %} + {% set _label = _reason %} + {% set _icon_colour = 'text-slate-400' %} +{% endif %} + +{% set _is_error = result.error_message or _reason in ['ERROR', 'PREREQUISITE_FAIL'] %} +{% set _panel_border = 'border-red-200 bg-red-50' if _is_error else 'border-slate-200 bg-slate-50' %} + +
+ + {# ── Header row ──────────────────────────────────────────────────── #} +
+ Evaluation Result + + {{ _label }} + +
+ + {# ── Main result card ─────────────────────────────────────────────── #} +
+
+ + {# Value #} +
+
Value
+
{{ value_json }}
+
+ + {# Variation #} + {% if result.variation %} +
+
Variation
+
+ {{ result.variation }} +
+
+ {% endif %} + + {# Reason #} +
+
Reason
+
{{ _reason }}
+
+ + {# Rule (RULE_MATCH) #} + {% if result.rule_id %} +
+
Matched rule
+
+ {% if rule_description %} + {{ rule_description }} + {% endif %} + {{ result.rule_id }} +
+
+ {% endif %} + + {# Prerequisite failed #} + {% if result.prerequisite_key %} +
+
Failed prerequisite
+
+ {{ result.prerequisite_key }} +
+
+ {% endif %} + + {# Error message #} + {% if result.error_message %} +
+
Error
+
{{ result.error_message }}
+
+ {% endif %} + +
+
+ + {# ── Context summary ──────────────────────────────────────────────── #} +
+

Context evaluated

+
+
+
key
+
{{ ctx_key }}
+
+
+
kind
+
{{ ctx_kind }}
+
+ {% for attr_k, attr_v in ctx_attributes.items() %} +
+
{{ attr_k }}
+
{{ attr_v }}
+
+ {% endfor %} +
+
+ + {# ── Evaluate again link ───────────────────────────────────────────── #} +
+ +
+ +
diff --git a/shield/dashboard/templates/partials/flag_row.html b/shield/dashboard/templates/partials/flag_row.html new file mode 100644 index 0000000..bcbe1f4 --- /dev/null +++ b/shield/dashboard/templates/partials/flag_row.html @@ -0,0 +1,131 @@ +{% set _colour = flag_type_colours.get(flag.type.value, 'slate') %} +
+ + {{ flag.key }} + + + + {{ flag.type.value }} + + + {% if flag.enabled %} + + Enabled + + {% else %} + + Disabled + + {% endif %} + + +
+ {# Eval #} + + + {# View #} + + + + + + View + + + {# Enable / Disable #} + {% if flag.enabled %} + + {% else %} + + {% endif %} + + {# Delete #} + +
+ +
+
+
+ + + +
+

No feature flags yet

+

+ Create your first feature flag to start controlling feature rollouts and experiments. +

+ +
+
- {{ state.path }} +
+ {{ state | clean_path }} + {% if state.service %} + {{ state.service }} + {% endif %} +
{% if state.force_active %} diff --git a/shield/dashboard/templates/partials/segment_row.html b/shield/dashboard/templates/partials/segment_row.html new file mode 100644 index 0000000..bb59941 --- /dev/null +++ b/shield/dashboard/templates/partials/segment_row.html @@ -0,0 +1,96 @@ +
+ + + + + {% set inc_count = segment.included | length %} + {% if inc_count > 0 %} + + {{ inc_count }} + + {% else %} + 0 + {% endif %} + + {% set exc_count = segment.excluded | length %} + {% if exc_count > 0 %} + + {{ exc_count }} + + {% else %} + 0 + {% endif %} + + +
+ {# Edit #} + + + {# Delete #} + +
+ +
+
+
+ + + +
+

No segments yet

+

+ Create segments to group users and use them in feature flag targeting rules. +

+ +
+
+ + + + + + + + + {% 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/dashboard/templates/segments.html b/shield/dashboard/templates/segments.html new file mode 100644 index 0000000..5376e03 --- /dev/null +++ b/shield/dashboard/templates/segments.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} + +{% block content %} + +{# ── Page header ───────────────────────────────────────────────────── #} +
+
+

Segments

+

Group users for use in flag targeting rules

+
+ +
+ +{# ── Stats + Search row ─────────────────────────────────────────────── #} +{% set total = segments | length %} +{% set ns = namespace(included_total=0, excluded_total=0) %} +{% for s in segments %} +{% set ns.included_total = ns.included_total + (s.included | length) %} +{% set ns.excluded_total = ns.excluded_total + (s.excluded | length) %} +{% endfor %} +
+ {# Stats — 3 cols on all sizes, sits left on lg+ #} +
+
+ +
+
{{ total }}
+
Segments
+
+
+
+ +
+
{{ ns.included_total }}
+
Included
+
+
+
+ +
+
{{ ns.excluded_total }}
+
Excluded
+
+
+
+ + {# Search — below stats on mobile, right side on lg+ #} + {% if segments %} +
+ + + + +
+ {% endif %} +
+ +{# ── Segments table — ALWAYS rendered so #segment-table-body always exists ─ #} +
+
+ + + + + + + + + + + + + {% include "partials/segment_rows.html" with context %} + +
KeyNameIncludedExcludedActions
+
+
+ +{% endblock %} diff --git a/shield/sdk/__init__.py b/shield/sdk/__init__.py new file mode 100644 index 0000000..9e31a14 --- /dev/null +++ b/shield/sdk/__init__.py @@ -0,0 +1,254 @@ +"""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 use_openfeature( + self, + hooks: list[Any] | None = None, + domain: str = "shield", + ) -> None: + """Enable OpenFeature feature-flag evaluation for this SDK client. + + Must be called **before** :meth:`attach`. + + Activates :class:`~shield.sdk.flag_provider.ShieldSDKFlagProvider` + which: + + * On startup fetches all flags/segments from the Shield Server via + ``GET /api/flags`` and ``GET /api/segments``. + * Stays current by listening to ``flag_updated``, ``flag_deleted``, + ``segment_updated``, and ``segment_deleted`` events on the + existing SSE connection — no extra network connections needed. + + Usage:: + + sdk = ShieldSDK(server_url="http://shield:9000", app_id="my-svc") + sdk.use_openfeature() + sdk.attach(app) + + # Evaluate anywhere via the engine's flag client: + value = await sdk.engine.flag_client.get_boolean_value( + "my-flag", default_value=False + ) + + Parameters + ---------- + hooks: + Optional list of OpenFeature :class:`Hook` objects to register + globally for this provider. + domain: + OpenFeature provider domain name (default ``"shield"``). + """ + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + provider = ShieldSDKFlagProvider(self._backend) + self._engine.use_openfeature(provider=provider, hooks=hooks, domain=domain) + + 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. + # Use the same method-prefixed key format as ShieldRouter + # (e.g. "GET:/api/payments") so that routes registered by + # ShieldRouter before the SDK startup don't create duplicates + # with missing-method variants. + shield_routes: list[tuple[str, dict[str, Any]]] = [] + for route in app.routes: + if not isinstance(route, APIRoute): + continue + if not hasattr(route.endpoint, "__shield_meta__"): + continue + meta: dict[str, Any] = route.endpoint.__shield_meta__ + methods: set[str] = route.methods or set() + if methods: + for method in sorted(methods): + shield_routes.append((f"{method}:{route.path}", meta)) + else: + shield_routes.append((route.path, meta)) + + if shield_routes: + # register_batch() is persistence-first: routes already present + # in the cache (synced from server) are skipped. All set_state() + # calls queue to _pending while _startup_done is False; they are + # flushed in a single HTTP round-trip by _flush_pending() below. + await self._engine.register_batch(shield_routes) + + # Push any truly new routes (not already on the server) in one HTTP + # round-trip, then mark startup complete so that subsequent + # set_state() calls (runtime mutations) push immediately. + 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/sdk/flag_provider.py b/shield/sdk/flag_provider.py new file mode 100644 index 0000000..a9856af --- /dev/null +++ b/shield/sdk/flag_provider.py @@ -0,0 +1,174 @@ +"""ShieldSDKFlagProvider — OpenFeature provider for SDK clients. + +Syncs feature flags and segments from a remote Shield Server: + +1. On ``initialize()`` it fetches all flags/segments via REST + (``GET /api/flags`` and ``GET /api/segments``). +2. It then subscribes to the Shield Server's SSE stream + (``GET /api/sdk/events``) so any subsequent flag/segment mutations + made on the server side are reflected locally with no polling. + +Use ``ShieldSDK.use_openfeature()`` to activate this provider. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +from shield.core.feature_flags.models import FeatureFlag, Segment # noqa: E402 +from shield.core.feature_flags.provider import ShieldOpenFeatureProvider # noqa: E402 + +if TYPE_CHECKING: + from shield.core.backends.server import ShieldServerBackend + +logger = logging.getLogger(__name__) + +__all__ = ["ShieldSDKFlagProvider"] + + +class ShieldSDKFlagProvider(ShieldOpenFeatureProvider): + """OpenFeature provider that hot-reloads flags from a Shield Server. + + Parameters + ---------- + backend: + The :class:`~shield.core.backends.server.ShieldServerBackend` + instance used by this SDK — the same one passed to + :class:`~shield.sdk.ShieldSDK`. + """ + + def __init__(self, backend: ShieldServerBackend) -> None: + super().__init__(backend) + self._server_backend = backend + self._watch_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ + # OpenFeature lifecycle + # ------------------------------------------------------------------ + + def initialize(self, evaluation_context: Any = None) -> None: + """Fetch flags from the server and start the SSE watch task. + + The OpenFeature SDK calls this synchronously; async work is + scheduled as asyncio tasks so no coroutine is left unawaited. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # no event loop — skip (unit-test or import-time context) + loop.create_task(self._async_initialize(), name="shield-sdk-flag-init") + + async def _async_initialize(self) -> None: + await self._fetch_from_server() + self._watch_task = asyncio.create_task(self._watch_sse(), name="shield-sdk-flag-watch") + + def shutdown(self) -> None: + """Cancel the SSE watcher task. + + The OpenFeature SDK calls this synchronously; the task is + cancelled without awaiting so no coroutine is left unawaited. + """ + if self._watch_task is not None: + self._watch_task.cancel() + self._watch_task = None + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + async def _fetch_from_server(self) -> None: + """Pull current flags and segments from the Shield Server REST API.""" + client = self._server_backend._client + if client is None: + logger.warning( + "ShieldSDKFlagProvider: HTTP client not ready — skipping initial flag fetch" + ) + return + + try: + resp = await client.get("/api/flags") + if resp.status_code == 200: + data = resp.json() + # The API returns either a list directly or {"flags": [...]} + items = data if isinstance(data, list) else data.get("flags", []) + for item in items: + try: + flag = FeatureFlag.model_validate(item) + self._flags[flag.key] = flag + # Also populate the backend's raw cache so + # load_all_flags() returns the right data. + self._server_backend._flag_cache[flag.key] = item + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + logger.warning("ShieldSDKFlagProvider: could not fetch flags from server") + + try: + resp = await client.get("/api/segments") + if resp.status_code == 200: + data = resp.json() + items = data if isinstance(data, list) else data.get("segments", []) + for item in items: + try: + seg = Segment.model_validate(item) + self._segments[seg.key] = seg + self._server_backend._segment_cache[seg.key] = item + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + logger.warning("ShieldSDKFlagProvider: could not fetch segments from server") + + logger.info( + "ShieldSDKFlagProvider: loaded %d flag(s), %d segment(s) from server", + len(self._flags), + len(self._segments), + ) + + async def _watch_sse(self) -> None: + """Subscribe to the backend's flag change queue and update local cache.""" + try: + async for event in self._server_backend.subscribe_flag_changes(): + etype = event.get("type") + + if etype == "flag_updated": + raw = event.get("flag") + if raw is not None: + try: + flag = FeatureFlag.model_validate(raw) + self._flags[flag.key] = flag + logger.debug("ShieldSDKFlagProvider: flag hot-reloaded — %s", flag.key) + except Exception: # noqa: BLE001 + pass + + elif etype == "flag_deleted": + key = event.get("key", "") + self._flags.pop(key, None) + logger.debug("ShieldSDKFlagProvider: flag removed — %s", key) + + elif etype == "segment_updated": + raw = event.get("segment") + if raw is not None: + try: + seg = Segment.model_validate(raw) + self._segments[seg.key] = seg + logger.debug( + "ShieldSDKFlagProvider: segment hot-reloaded — %s", seg.key + ) + except Exception: # noqa: BLE001 + pass + + elif etype == "segment_deleted": + key = event.get("key", "") + self._segments.pop(key, None) + logger.debug("ShieldSDKFlagProvider: segment removed — %s", key) + + except asyncio.CancelledError: + raise + except Exception: + logger.exception("ShieldSDKFlagProvider: SSE watch error") 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/admin/test_flag_api.py b/tests/admin/test_flag_api.py new file mode 100644 index 0000000..47d3926 --- /dev/null +++ b/tests/admin/test_flag_api.py @@ -0,0 +1,651 @@ +"""Tests for the feature flag + segment REST API endpoints in ShieldAdmin. + +All tests use an in-process ASGI transport — no real server needed. +The admin is mounted with ``enable_flags=True`` so flag routes are active. +""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.admin.app import ShieldAdmin +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.models import ( + FeatureFlag, + Segment, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def engine() -> ShieldEngine: + return ShieldEngine() + + +@pytest.fixture +def admin(engine: ShieldEngine): + """ShieldAdmin with flags enabled, no auth.""" + return ShieldAdmin(engine=engine, enable_flags=True) + + +@pytest.fixture +async def client(admin) -> AsyncClient: + async with AsyncClient( + transport=ASGITransport(app=admin), + base_url="http://testserver", + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag_payload(key: str = "my_flag", enabled: bool = True) -> dict: + return { + "key": key, + "name": "My Flag", + "type": "boolean", + "variations": [ + {"name": "on", "value": True}, + {"name": "off", "value": False}, + ], + "off_variation": "off", + "fallthrough": "off", + "enabled": enabled, + } + + +def _segment_payload(key: str = "beta") -> dict: + return { + "key": key, + "name": "Beta Users", + "included": ["user_1", "user_2"], + "excluded": [], + "rules": [], + } + + +# --------------------------------------------------------------------------- +# Flag API — enable_flags=False (routes not mounted) +# --------------------------------------------------------------------------- + + +class TestFlagsNotMounted: + async def test_flag_routes_absent_when_disabled(self, engine): + admin_no_flags = ShieldAdmin(engine=engine, enable_flags=False) + async with AsyncClient( + transport=ASGITransport(app=admin_no_flags), + base_url="http://testserver", + ) as c: + resp = await c.get("/api/flags") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flag API — list +# --------------------------------------------------------------------------- + + +class TestListFlags: + async def test_empty(self, client): + resp = await client.get("/api/flags") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_returns_saved_flag(self, client, engine): + + flag = FeatureFlag.model_validate(_bool_flag_payload()) + await engine.save_flag(flag) + + resp = await client.get("/api/flags") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["key"] == "my_flag" + + +# --------------------------------------------------------------------------- +# Flag API — create +# --------------------------------------------------------------------------- + + +class TestCreateFlag: + async def test_create_returns_201(self, client): + resp = await client.post("/api/flags", json=_bool_flag_payload()) + assert resp.status_code == 201 + assert resp.json()["key"] == "my_flag" + + async def test_create_persists_flag(self, client, engine): + await client.post("/api/flags", json=_bool_flag_payload()) + flag = await engine.get_flag("my_flag") + assert flag is not None + assert flag.key == "my_flag" + + async def test_create_conflict_returns_409(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.post("/api/flags", json=_bool_flag_payload()) + assert resp.status_code == 409 + + async def test_create_invalid_body_returns_400(self, client): + resp = await client.post("/api/flags", json={"key": "bad"}) + assert resp.status_code == 400 + + async def test_create_string_flag(self, client): + payload = { + "key": "color_flag", + "name": "Color", + "type": "string", + "variations": [ + {"name": "blue", "value": "blue"}, + {"name": "red", "value": "red"}, + ], + "off_variation": "blue", + "fallthrough": "red", + } + resp = await client.post("/api/flags", json=payload) + assert resp.status_code == 201 + assert resp.json()["type"] == "string" + + async def test_create_with_targeting_rule(self, client): + payload = _bool_flag_payload() + payload["rules"] = [ + { + "clauses": [{"attribute": "role", "operator": "is", "values": ["admin"]}], + "variation": "on", + } + ] + resp = await client.post("/api/flags", json=payload) + assert resp.status_code == 201 + assert len(resp.json()["rules"]) == 1 + + +# --------------------------------------------------------------------------- +# Flag API — get +# --------------------------------------------------------------------------- + + +class TestGetFlag: + async def test_get_existing(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.get("/api/flags/my_flag") + assert resp.status_code == 200 + assert resp.json()["key"] == "my_flag" + + async def test_get_missing_returns_404(self, client): + resp = await client.get("/api/flags/nonexistent") + assert resp.status_code == 404 + + async def test_get_returns_full_model(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.get("/api/flags/my_flag") + data = resp.json() + assert "variations" in data + assert "off_variation" in data + assert "fallthrough" in data + assert "enabled" in data + + +# --------------------------------------------------------------------------- +# Flag API — update +# --------------------------------------------------------------------------- + + +class TestUpdateFlag: + async def test_put_updates_flag(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + updated = _bool_flag_payload() + updated["name"] = "Updated Name" + resp = await client.put("/api/flags/my_flag", json=updated) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated Name" + + async def test_put_creates_if_missing(self, client): + # PUT is an upsert — creates if not exists + resp = await client.put("/api/flags/new_flag", json=_bool_flag_payload("new_flag")) + assert resp.status_code == 200 + assert resp.json()["key"] == "new_flag" + + async def test_put_key_mismatch_returns_400(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.put( + "/api/flags/my_flag", + json=_bool_flag_payload("other_key"), + ) + assert resp.status_code == 400 + + async def test_put_without_key_in_body_uses_url_key(self, client): + payload = _bool_flag_payload() + payload.pop("key") + resp = await client.put("/api/flags/my_flag", json=payload) + assert resp.status_code == 200 + assert resp.json()["key"] == "my_flag" + + +# --------------------------------------------------------------------------- +# Flag API — enable / disable +# --------------------------------------------------------------------------- + + +class TestEnableDisableFlag: + async def test_enable_flag(self, client): + payload = _bool_flag_payload(enabled=False) + await client.post("/api/flags", json=payload) + + resp = await client.post("/api/flags/my_flag/enable") + assert resp.status_code == 200 + assert resp.json()["enabled"] is True + + async def test_disable_flag(self, client): + await client.post("/api/flags", json=_bool_flag_payload(enabled=True)) + + resp = await client.post("/api/flags/my_flag/disable") + assert resp.status_code == 200 + assert resp.json()["enabled"] is False + + async def test_enable_missing_returns_404(self, client): + resp = await client.post("/api/flags/nonexistent/enable") + assert resp.status_code == 404 + + async def test_disable_missing_returns_404(self, client): + resp = await client.post("/api/flags/nonexistent/disable") + assert resp.status_code == 404 + + async def test_enable_updates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_enable_cache") + await client.post("/api/flags", json=_bool_flag_payload(enabled=False)) + await client.post("/api/flags/my_flag/enable") + flag = engine._flag_provider._flags.get("my_flag") + assert flag is not None + assert flag.enabled is True + + +# --------------------------------------------------------------------------- +# Flag API — delete +# --------------------------------------------------------------------------- + + +class TestDeleteFlag: + async def test_delete_existing(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.delete("/api/flags/my_flag") + assert resp.status_code == 200 + assert resp.json()["deleted"] == "my_flag" + + async def test_delete_removes_from_list(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + await client.delete("/api/flags/my_flag") + resp = await client.get("/api/flags") + assert resp.json() == [] + + async def test_delete_missing_returns_404(self, client): + resp = await client.delete("/api/flags/nonexistent") + assert resp.status_code == 404 + + async def test_delete_updates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_delete_cache") + await client.post("/api/flags", json=_bool_flag_payload()) + await client.delete("/api/flags/my_flag") + assert "my_flag" not in engine._flag_provider._flags + + +# --------------------------------------------------------------------------- +# Flag API — evaluate (debug endpoint) +# --------------------------------------------------------------------------- + + +class TestEvaluateFlag: + async def test_evaluate_fallthrough(self, client): + payload = _bool_flag_payload() + payload["fallthrough"] = "on" + await client.post("/api/flags", json=payload) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1"}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["value"] is True + assert data["reason"] == "FALLTHROUGH" + + async def test_evaluate_disabled_flag(self, client): + await client.post("/api/flags", json=_bool_flag_payload(enabled=False)) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1"}}, + ) + assert resp.status_code == 200 + assert resp.json()["reason"] == "OFF" + + async def test_evaluate_with_targeting_rule(self, client): + payload = _bool_flag_payload() + payload["rules"] = [ + { + "clauses": [{"attribute": "role", "operator": "is", "values": ["admin"]}], + "variation": "on", + } + ] + payload["fallthrough"] = "off" + await client.post("/api/flags", json=payload) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1", "attributes": {"role": "admin"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["value"] is True + assert data["reason"] == "RULE_MATCH" + + async def test_evaluate_missing_flag_returns_404(self, client): + resp = await client.post( + "/api/flags/nonexistent/evaluate", + json={"context": {"key": "user_1"}}, + ) + assert resp.status_code == 404 + + async def test_evaluate_no_context_uses_anonymous(self, client): + payload = _bool_flag_payload() + payload["fallthrough"] = "on" + await client.post("/api/flags", json=payload) + + resp = await client.post("/api/flags/my_flag/evaluate", json={}) + assert resp.status_code == 200 + + async def test_evaluate_returns_all_fields(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "u1"}}, + ) + data = resp.json() + assert "flag_key" in data + assert "value" in data + assert "variation" in data + assert "reason" in data + assert "rule_id" in data + assert "prerequisite_key" in data + + +# --------------------------------------------------------------------------- +# Segment API — list +# --------------------------------------------------------------------------- + + +class TestListSegments: + async def test_empty(self, client): + resp = await client.get("/api/segments") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_returns_saved_segment(self, client, engine): + seg = Segment.model_validate(_segment_payload()) + await engine.save_segment(seg) + + resp = await client.get("/api/segments") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["key"] == "beta" + + +# --------------------------------------------------------------------------- +# Segment API — create +# --------------------------------------------------------------------------- + + +class TestCreateSegment: + async def test_create_returns_201(self, client): + resp = await client.post("/api/segments", json=_segment_payload()) + assert resp.status_code == 201 + assert resp.json()["key"] == "beta" + + async def test_create_conflict_returns_409(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.post("/api/segments", json=_segment_payload()) + assert resp.status_code == 409 + + async def test_create_with_rules(self, client): + payload = _segment_payload() + payload["rules"] = [ + {"clauses": [{"attribute": "plan", "operator": "is", "values": ["pro"]}]} + ] + resp = await client.post("/api/segments", json=payload) + assert resp.status_code == 201 + assert len(resp.json()["rules"]) == 1 + + async def test_create_invalid_body_returns_400(self, client): + resp = await client.post("/api/segments", json={"bad": "data"}) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Segment API — get +# --------------------------------------------------------------------------- + + +class TestGetSegment: + async def test_get_existing(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.get("/api/segments/beta") + assert resp.status_code == 200 + assert resp.json()["key"] == "beta" + + async def test_get_missing_returns_404(self, client): + resp = await client.get("/api/segments/nonexistent") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Segment API — update +# --------------------------------------------------------------------------- + + +class TestUpdateSegment: + async def test_put_updates_segment(self, client): + await client.post("/api/segments", json=_segment_payload()) + updated = _segment_payload() + updated["name"] = "Updated Beta" + resp = await client.put("/api/segments/beta", json=updated) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated Beta" + + async def test_put_key_mismatch_returns_400(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.put( + "/api/segments/beta", + json=_segment_payload("other"), + ) + assert resp.status_code == 400 + + async def test_put_without_key_uses_url_key(self, client): + payload = _segment_payload() + payload.pop("key") + resp = await client.put("/api/segments/beta", json=payload) + assert resp.status_code == 200 + assert resp.json()["key"] == "beta" + + +# --------------------------------------------------------------------------- +# Segment API — delete +# --------------------------------------------------------------------------- + + +class TestDeleteSegment: + async def test_delete_existing(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.delete("/api/segments/beta") + assert resp.status_code == 200 + assert resp.json()["deleted"] == "beta" + + async def test_delete_removes_from_list(self, client): + await client.post("/api/segments", json=_segment_payload()) + await client.delete("/api/segments/beta") + resp = await client.get("/api/segments") + assert resp.json() == [] + + async def test_delete_missing_returns_404(self, client): + resp = await client.delete("/api/segments/nonexistent") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Provider cache sync +# --------------------------------------------------------------------------- + + +class TestProviderCacheSync: + """Verify that REST operations propagate into the provider's in-memory cache.""" + + async def test_create_flag_populates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_create_sync") + await client.post("/api/flags", json=_bool_flag_payload()) + assert "my_flag" in engine._flag_provider._flags + + async def test_delete_flag_removes_from_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_del_sync") + await client.post("/api/flags", json=_bool_flag_payload()) + await client.delete("/api/flags/my_flag") + assert "my_flag" not in engine._flag_provider._flags + + async def test_create_segment_populates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_seg_sync") + await client.post("/api/segments", json=_segment_payload()) + assert "beta" in engine._flag_provider._segments + + async def test_delete_segment_removes_from_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_seg_del_sync") + await client.post("/api/segments", json=_segment_payload()) + await client.delete("/api/segments/beta") + assert "beta" not in engine._flag_provider._segments + + +# --------------------------------------------------------------------------- +# Evaluate with segment targeting +# --------------------------------------------------------------------------- + + +class TestEvaluateWithSegment: + async def test_segment_rule_resolves_correctly(self, client, engine): + # Create a segment + await client.post( + "/api/segments", + json={ + "key": "pro_users", + "name": "Pro Users", + "rules": [ + {"clauses": [{"attribute": "plan", "operator": "is", "values": ["pro"]}]} + ], + }, + ) + + # Create a flag that targets the segment + payload = _bool_flag_payload() + payload["fallthrough"] = "off" + payload["rules"] = [ + { + "clauses": [{"attribute": "", "operator": "in_segment", "values": ["pro_users"]}], + "variation": "on", + } + ] + await client.post("/api/flags", json=payload) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1", "attributes": {"plan": "pro"}}}, + ) + assert resp.status_code == 200 + assert resp.json()["value"] is True + + +# --------------------------------------------------------------------------- +# Flag API — PATCH (partial update / LaunchDarkly-style mutation) +# --------------------------------------------------------------------------- + + +class TestPatchFlag: + async def test_patch_name(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"name": "Renamed Flag"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "Renamed Flag" + # Other fields untouched + assert resp.json()["type"] == "boolean" + assert resp.json()["key"] == "my_flag" + + async def test_patch_persists(self, client, engine): + await client.post("/api/flags", json=_bool_flag_payload()) + await client.patch("/api/flags/my_flag", json={"name": "Persisted"}) + flag = await engine.get_flag("my_flag") + assert flag.name == "Persisted" + + async def test_patch_description(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"description": "hello"}) + assert resp.status_code == 200 + assert resp.json()["description"] == "hello" + + async def test_patch_off_variation(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"off_variation": "on"}) + assert resp.status_code == 200 + assert resp.json()["off_variation"] == "on" + + async def test_patch_fallthrough(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"fallthrough": "on"}) + assert resp.status_code == 200 + assert resp.json()["fallthrough"] == "on" + + async def test_patch_ignores_key(self, client): + """key must be immutable — PATCH body key field is silently ignored.""" + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"key": "HACKED", "name": "ok"}) + assert resp.status_code == 200 + assert resp.json()["key"] == "my_flag" + + async def test_patch_ignores_type(self, client): + """type must be immutable — PATCH body type field is silently ignored.""" + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"type": "string", "name": "ok"}) + assert resp.status_code == 200 + assert resp.json()["type"] == "boolean" + + async def test_patch_missing_flag_returns_404(self, client): + resp = await client.patch("/api/flags/no_such_flag", json={"name": "x"}) + assert resp.status_code == 404 + + async def test_patch_invalid_body_returns_400(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", content=b"not json") + assert resp.status_code == 400 + + async def test_patch_invalid_variation_name_returns_400(self, client): + """off_variation must reference an existing variation name.""" + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch( + "/api/flags/my_flag", + json={"off_variation": "nonexistent_variation"}, + ) + assert resp.status_code == 400 + + async def test_patch_replaces_rules(self, client, engine): + await client.post("/api/flags", json=_bool_flag_payload()) + new_rules = [ + { + "clauses": [{"attribute": "plan", "operator": "is", "values": ["pro"]}], + "variation": "on", + } + ] + resp = await client.patch("/api/flags/my_flag", json={"rules": new_rules}) + assert resp.status_code == 200 + flag = await engine.get_flag("my_flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].attribute == "plan" diff --git a/tests/core/feature_flags/__init__.py b/tests/core/feature_flags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/feature_flags/test_client.py b/tests/core/feature_flags/test_client.py new file mode 100644 index 0000000..6d50d7c --- /dev/null +++ b/tests/core/feature_flags/test_client.py @@ -0,0 +1,270 @@ +"""Tests for ShieldFeatureClient and engine.use_openfeature().""" + +from __future__ import annotations + +from shield.core.backends.memory import MemoryBackend +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.client import ShieldFeatureClient +from shield.core.feature_flags.models import ( + EvaluationContext, + FeatureFlag, + FlagType, + FlagVariation, +) +from shield.core.feature_flags.provider import ShieldOpenFeatureProvider + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag(key="feat", fallthrough_variation="on", enabled=True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="Feat", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=fallthrough_variation, + enabled=enabled, + ) + + +def _string_flag(key="color") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Color", + type=FlagType.STRING, + variations=[ + FlagVariation(name="blue", value="blue"), + FlagVariation(name="red", value="red"), + ], + off_variation="blue", + fallthrough="red", + ) + + +def _int_flag(key="limit") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Limit", + type=FlagType.INTEGER, + variations=[ + FlagVariation(name="low", value=10), + FlagVariation(name="high", value=100), + ], + off_variation="low", + fallthrough="high", + ) + + +def _float_flag(key="rate") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Rate", + type=FlagType.FLOAT, + variations=[ + FlagVariation(name="slow", value=0.1), + FlagVariation(name="fast", value=0.9), + ], + off_variation="slow", + fallthrough="fast", + ) + + +def _object_flag(key="cfg") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Config", + type=FlagType.JSON, + variations=[ + FlagVariation(name="default", value={"x": 1}), + FlagVariation(name="premium", value={"x": 10}), + ], + off_variation="default", + fallthrough="premium", + ) + + +class _FakeBackend: + def __init__(self, flags=()): + self._flags = list(flags) + + async def load_all_flags(self): + return self._flags + + async def load_all_segments(self): + return [] + + # Minimal stubs so ShieldEngine can use this as backend + async def startup(self): + pass + + async def shutdown(self): + pass + + async def subscribe_global_config(self): + raise NotImplementedError + + async def subscribe_route_state(self): + raise NotImplementedError + + async def subscribe_rate_limit_policies(self): + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# engine.use_openfeature() +# --------------------------------------------------------------------------- + + +class TestUseOpenFeature: + def test_returns_shield_feature_client(self): + engine = ShieldEngine() + client = engine.use_openfeature(domain="test_uof") + assert isinstance(client, ShieldFeatureClient) + + def test_flag_client_property_returns_client(self): + engine = ShieldEngine() + client = engine.use_openfeature(domain="test_prop") + assert engine.flag_client is client + + def test_flag_client_none_before_use_openfeature(self): + engine = ShieldEngine() + assert engine.flag_client is None + + def test_custom_provider_accepted(self): + engine = ShieldEngine() + custom = ShieldOpenFeatureProvider(MemoryBackend()) + client = engine.use_openfeature(provider=custom, domain="test_custom") + assert engine._flag_provider is custom + assert isinstance(client, ShieldFeatureClient) + + def test_default_provider_is_shield_provider(self): + engine = ShieldEngine() + engine.use_openfeature(domain="test_default_prov") + assert isinstance(engine._flag_provider, ShieldOpenFeatureProvider) + + async def test_start_initializes_provider(self): + initialized = [] + + class _TrackedProvider(ShieldOpenFeatureProvider): + def initialize(self, evaluation_context=None): + initialized.append(True) + + def shutdown(self): + pass + + engine = ShieldEngine() + engine.use_openfeature(provider=_TrackedProvider(MemoryBackend()), domain="test_start") + await engine.start() + assert initialized == [True] + await engine.stop() + + async def test_stop_shuts_down_provider(self): + shutdown = [] + + class _TrackedProvider(ShieldOpenFeatureProvider): + def initialize(self, evaluation_context=None): + pass + + def shutdown(self): + shutdown.append(True) + + engine = ShieldEngine() + engine.use_openfeature(provider=_TrackedProvider(MemoryBackend()), domain="test_stop") + await engine.start() + await engine.stop() + assert shutdown == [True] + + def test_use_openfeature_multiple_calls_replaces_provider(self): + engine = ShieldEngine() + engine.use_openfeature(domain="test_multi_1") + p1 = engine._flag_provider + engine.use_openfeature(domain="test_multi_2") + p2 = engine._flag_provider + # Both are valid providers; second call replaced the first. + assert p2 is not p1 + + +# --------------------------------------------------------------------------- +# ShieldFeatureClient — evaluation +# --------------------------------------------------------------------------- + + +class TestShieldFeatureClientEvaluation: + async def _make_client(self, flags, domain) -> ShieldFeatureClient: + """Wire up a provider with the given flags and return a client.""" + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=flags)) + await provider._load_all() + + import openfeature.api as of_api + + of_api.set_provider(provider, domain=domain) + return ShieldFeatureClient(domain=domain) + + async def test_get_boolean_value_true(self): + client = await self._make_client([_bool_flag(fallthrough_variation="on")], "cli_bool") + ctx = EvaluationContext(key="user_1") + result = await client.get_boolean_value("feat", False, ctx) + assert result is True + + async def test_get_boolean_value_missing_flag_returns_default(self): + client = await self._make_client([], "cli_bool_miss") + result = await client.get_boolean_value("missing", True) + assert result is True + + async def test_get_string_value(self): + client = await self._make_client([_string_flag()], "cli_str") + ctx = EvaluationContext(key="user_1") + result = await client.get_string_value("color", "default", ctx) + assert result == "red" + + async def test_get_string_value_missing_flag(self): + client = await self._make_client([], "cli_str_miss") + result = await client.get_string_value("missing", "fallback") + assert result == "fallback" + + async def test_get_integer_value(self): + client = await self._make_client([_int_flag()], "cli_int") + result = await client.get_integer_value("limit", 0) + assert result == 100 + + async def test_get_integer_value_missing(self): + client = await self._make_client([], "cli_int_miss") + result = await client.get_integer_value("limit", 99) + assert result == 99 + + async def test_get_float_value(self): + client = await self._make_client([_float_flag()], "cli_float") + result = await client.get_float_value("rate", 0.0) + assert abs(result - 0.9) < 1e-9 + + async def test_get_float_value_missing(self): + client = await self._make_client([], "cli_float_miss") + result = await client.get_float_value("rate", 1.5) + assert abs(result - 1.5) < 1e-9 + + async def test_get_object_value(self): + client = await self._make_client([_object_flag()], "cli_obj") + result = await client.get_object_value("cfg", {}) + assert result == {"x": 10} + + async def test_get_object_value_missing(self): + client = await self._make_client([], "cli_obj_miss") + result = await client.get_object_value("cfg", {"y": 2}) + assert result == {"y": 2} + + async def test_no_context_uses_anonymous(self): + client = await self._make_client([_bool_flag()], "cli_anon") + # Passing no context should not raise + result = await client.get_boolean_value("feat", False) + assert isinstance(result, bool) + + async def test_disabled_flag_returns_off_default(self): + client = await self._make_client([_bool_flag(enabled=False)], "cli_dis") + result = await client.get_boolean_value("feat", True) + assert result is False # off variation = False diff --git a/tests/core/feature_flags/test_evaluator.py b/tests/core/feature_flags/test_evaluator.py new file mode 100644 index 0000000..3ca0ac7 --- /dev/null +++ b/tests/core/feature_flags/test_evaluator.py @@ -0,0 +1,524 @@ +"""Tests for FlagEvaluator — pure unit tests, no I/O, no async. + +Every test constructs FeatureFlag / EvaluationContext objects directly +and calls FlagEvaluator.evaluate() synchronously. +""" + +from __future__ import annotations + +import pytest + +from shield.core.feature_flags.evaluator import FlagEvaluator +from shield.core.feature_flags.models import ( + EvaluationContext, + EvaluationReason, + FeatureFlag, + FlagType, + FlagVariation, + Operator, + Prerequisite, + RolloutVariation, + RuleClause, + Segment, + SegmentRule, + TargetingRule, +) + +# ── Fixtures and helpers ────────────────────────────────────────────────────── + + +def _flag( + key: str = "flag_a", + enabled: bool = True, + fallthrough: str | list = "off", + rules: list | None = None, + targets: dict | None = None, + prerequisites: list | None = None, + variations: list | None = None, +) -> FeatureFlag: + if variations is None: + variations = [ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ] + return FeatureFlag( + key=key, + name=key, + type=FlagType.BOOLEAN, + variations=variations, + off_variation="off", + fallthrough=fallthrough, + enabled=enabled, + rules=rules or [], + targets=targets or {}, + prerequisites=prerequisites or [], + ) + + +def _ctx(key: str = "user_1", **attrs: object) -> EvaluationContext: + return EvaluationContext(key=key, attributes=dict(attrs)) + + +def _rule(*clauses: RuleClause, variation: str = "on") -> TargetingRule: + return TargetingRule(clauses=list(clauses), variation=variation) + + +def _clause(attr: str, op: Operator, values: list) -> RuleClause: + return RuleClause(attribute=attr, operator=op, values=values) + + +@pytest.fixture +def evaluator() -> FlagEvaluator: + return FlagEvaluator(segments={}) + + +# ── Step 1: flag disabled ───────────────────────────────────────────────────── + + +class TestFlagDisabled: + def test_disabled_serves_off_variation(self, evaluator): + flag = _flag(enabled=False) + result = evaluator.evaluate(flag, _ctx(), {}) + assert result.value is False + assert result.variation == "off" + assert result.reason == EvaluationReason.OFF + + def test_disabled_ignores_rules(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(enabled=False, rules=[rule]) + result = evaluator.evaluate(flag, _ctx(role="admin"), {}) + assert result.reason == EvaluationReason.OFF + + def test_disabled_ignores_targets(self, evaluator): + flag = _flag(enabled=False, targets={"on": ["user_1"]}) + result = evaluator.evaluate(flag, _ctx("user_1"), {}) + assert result.reason == EvaluationReason.OFF + + +# ── Step 2: prerequisites ───────────────────────────────────────────────────── + + +class TestPrerequisites: + def test_prerequisite_met(self, evaluator): + auth_flag = _flag("auth_v2", fallthrough="on") + main_flag = _flag( + "checkout", + fallthrough="on", + prerequisites=[Prerequisite(flag_key="auth_v2", variation="on")], + ) + all_flags = {"auth_v2": auth_flag, "checkout": main_flag} + result = evaluator.evaluate(main_flag, _ctx(), all_flags) + assert result.reason == EvaluationReason.FALLTHROUGH + assert result.value is True + + def test_prerequisite_not_met(self, evaluator): + auth_flag = _flag("auth_v2", fallthrough="off") + main_flag = _flag( + "checkout", + prerequisites=[Prerequisite(flag_key="auth_v2", variation="on")], + ) + all_flags = {"auth_v2": auth_flag, "checkout": main_flag} + result = evaluator.evaluate(main_flag, _ctx(), all_flags) + assert result.reason == EvaluationReason.PREREQUISITE_FAIL + assert result.prerequisite_key == "auth_v2" + assert result.value is False + + def test_missing_prerequisite_flag(self, evaluator): + main_flag = _flag( + "checkout", + prerequisites=[Prerequisite(flag_key="missing_flag", variation="on")], + ) + result = evaluator.evaluate(main_flag, _ctx(), {"checkout": main_flag}) + assert result.reason == EvaluationReason.PREREQUISITE_FAIL + assert result.prerequisite_key == "missing_flag" + + def test_disabled_prerequisite_fails(self, evaluator): + auth_flag = _flag("auth_v2", enabled=False, fallthrough="on") + main_flag = _flag( + "checkout", + prerequisites=[Prerequisite(flag_key="auth_v2", variation="on")], + ) + all_flags = {"auth_v2": auth_flag, "checkout": main_flag} + result = evaluator.evaluate(main_flag, _ctx(), all_flags) + # auth_v2 is disabled → serves off_variation "off", not "on" → prereq fails + assert result.reason == EvaluationReason.PREREQUISITE_FAIL + + def test_depth_limit_protection(self, evaluator): + # Simulate deep recursion by calling with _depth at limit + flag = _flag( + "deep", + prerequisites=[Prerequisite(flag_key="other", variation="on")], + ) + result = evaluator.evaluate(flag, _ctx(), {flag.key: flag}, _depth=11) + assert result.reason == EvaluationReason.ERROR + + +# ── Step 3: individual targets ──────────────────────────────────────────────── + + +class TestIndividualTargets: + def test_targeted_context_served_correct_variation(self, evaluator): + flag = _flag(targets={"on": ["user_1", "user_2"], "off": ["user_99"]}) + result = evaluator.evaluate(flag, _ctx("user_1"), {}) + assert result.reason == EvaluationReason.TARGET_MATCH + assert result.variation == "on" + assert result.value is True + + def test_non_targeted_context_falls_through(self, evaluator): + flag = _flag(targets={"on": ["user_1"]}) + result = evaluator.evaluate(flag, _ctx("user_999"), {}) + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_targets_take_priority_over_rules(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"]), variation="off") + flag = _flag(targets={"on": ["user_1"]}, rules=[rule]) + result = evaluator.evaluate(flag, _ctx("user_1", role="admin"), {}) + # Individual target wins over matching rule + assert result.reason == EvaluationReason.TARGET_MATCH + assert result.variation == "on" + + +# ── Step 4: targeting rules ─────────────────────────────────────────────────── + + +class TestTargetingRules: + def test_single_clause_match(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(rules=[rule]) + result = evaluator.evaluate(flag, _ctx(role="admin"), {}) + assert result.reason == EvaluationReason.RULE_MATCH + assert result.rule_id == rule.id + assert result.value is True + + def test_single_clause_no_match(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(rules=[rule]) + result = evaluator.evaluate(flag, _ctx(role="user"), {}) + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_multiple_clauses_all_must_match(self, evaluator): + rule = _rule( + _clause("role", Operator.IS, ["admin"]), + _clause("plan", Operator.IS, ["pro"]), + ) + flag = _flag(rules=[rule]) + # Both match + r = evaluator.evaluate(flag, _ctx(role="admin", plan="pro"), {}) + assert r.reason == EvaluationReason.RULE_MATCH + # Only one matches + r = evaluator.evaluate(flag, _ctx(role="admin", plan="free"), {}) + assert r.reason == EvaluationReason.FALLTHROUGH + + def test_first_rule_wins(self, evaluator): + rule1 = TargetingRule( + id="rule1", + clauses=[_clause("role", Operator.IS, ["admin"])], + variation="on", + ) + rule2 = TargetingRule( + id="rule2", + clauses=[_clause("role", Operator.IS, ["admin"])], + variation="off", + ) + flag = _flag(rules=[rule1, rule2]) + result = evaluator.evaluate(flag, _ctx(role="admin"), {}) + assert result.rule_id == "rule1" + assert result.variation == "on" + + def test_missing_attribute_no_match(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(rules=[rule]) + result = evaluator.evaluate(flag, _ctx(), {}) # no role attr + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_rule_with_rollout(self, evaluator): + # Force a specific bucket by using a known key + rollout_rule = TargetingRule( + clauses=[_clause("plan", Operator.IS, ["pro"])], + rollout=[ + RolloutVariation(variation="on", weight=100_000), + RolloutVariation(variation="off", weight=0), + ], + ) + flag = _flag(rules=[rollout_rule]) + result = evaluator.evaluate(flag, _ctx(plan="pro"), {}) + assert result.reason == EvaluationReason.RULE_MATCH + assert result.variation == "on" + + +# ── Step 5: fallthrough ─────────────────────────────────────────────────────── + + +class TestFallthrough: + def test_fixed_variation_fallthrough(self, evaluator): + flag = _flag(fallthrough="off") + result = evaluator.evaluate(flag, _ctx(), {}) + assert result.reason == EvaluationReason.FALLTHROUGH + assert result.variation == "off" + assert result.value is False + + def test_rollout_fallthrough_deterministic(self, evaluator): + flag = _flag( + fallthrough=[ + RolloutVariation(variation="on", weight=100_000), + ] + ) + # 100% → always "on" + for i in range(10): + result = evaluator.evaluate(flag, _ctx(f"user_{i}"), {}) + assert result.variation == "on" + + def test_rollout_fallthrough_stable(self, evaluator): + """Same context always gets the same bucket.""" + flag = _flag( + fallthrough=[ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ] + ) + ctx = _ctx("stable_user") + first = evaluator.evaluate(flag, ctx, {}).variation + for _ in range(5): + assert evaluator.evaluate(flag, ctx, {}).variation == first + + +# ── Operator tests ──────────────────────────────────────────────────────────── + + +class TestOperators: + """One test per operator group.""" + + def _eval(self, evaluator, op, actual, values, negate=False): + clause = RuleClause(attribute="x", operator=op, values=values, negate=negate) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key="u", attributes={"x": actual} if actual is not None else {}) + result = evaluator.evaluate(flag, ctx, {}) + return result.reason == EvaluationReason.RULE_MATCH + + # ── Equality + def test_is_match(self, evaluator): + assert self._eval(evaluator, Operator.IS, "admin", ["admin"]) + + def test_is_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.IS, "user", ["admin"]) + + def test_is_not_match(self, evaluator): + assert self._eval(evaluator, Operator.IS_NOT, "user", ["admin"]) + + def test_is_not_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.IS_NOT, "admin", ["admin"]) + + # ── String + def test_contains(self, evaluator): + assert self._eval(evaluator, Operator.CONTAINS, "hello world", ["world"]) + + def test_not_contains(self, evaluator): + assert self._eval(evaluator, Operator.NOT_CONTAINS, "hello", ["world"]) + + def test_starts_with(self, evaluator): + assert self._eval(evaluator, Operator.STARTS_WITH, "prefix_key", ["prefix"]) + + def test_ends_with(self, evaluator): + assert self._eval(evaluator, Operator.ENDS_WITH, "key_suffix", ["suffix"]) + + def test_matches_regex(self, evaluator): + assert self._eval(evaluator, Operator.MATCHES, "user@example.com", [r"@\w+\.com"]) + + def test_not_matches_regex(self, evaluator): + assert self._eval(evaluator, Operator.NOT_MATCHES, "foobar", [r"@\w+\.com"]) + + def test_invalid_regex_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.MATCHES, "test", ["[invalid"]) + + # ── Numeric + def test_gt(self, evaluator): + assert self._eval(evaluator, Operator.GT, 10, [5]) + + def test_gt_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.GT, 3, [5]) + + def test_gte(self, evaluator): + assert self._eval(evaluator, Operator.GTE, 5, [5]) + + def test_lt(self, evaluator): + assert self._eval(evaluator, Operator.LT, 3, [5]) + + def test_lte(self, evaluator): + assert self._eval(evaluator, Operator.LTE, 5, [5]) + + def test_numeric_non_numeric_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.GT, "abc", [5]) + + # ── Date (lexicographic) + def test_before(self, evaluator): + assert self._eval(evaluator, Operator.BEFORE, "2025-01-01", ["2026-01-01"]) + + def test_after(self, evaluator): + assert self._eval(evaluator, Operator.AFTER, "2026-01-01", ["2025-01-01"]) + + # ── Collection + def test_in(self, evaluator): + assert self._eval(evaluator, Operator.IN, "admin", ["admin", "moderator"]) + + def test_in_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.IN, "user", ["admin"]) + + def test_not_in(self, evaluator): + assert self._eval(evaluator, Operator.NOT_IN, "user", ["admin"]) + + # ── Negate + def test_negate_reverses_result(self, evaluator): + assert self._eval(evaluator, Operator.IS, "admin", ["admin"], negate=False) + assert not self._eval(evaluator, Operator.IS, "admin", ["admin"], negate=True) + + # ── Multiple values (OR) + def test_multiple_values_any_match(self, evaluator): + assert self._eval(evaluator, Operator.IS, "moderator", ["admin", "moderator", "staff"]) + + # ── Missing attribute + def test_missing_attribute_is_not(self, evaluator): + # IS_NOT with None still works (None != "admin" is True) + assert self._eval(evaluator, Operator.IS_NOT, None, ["admin"]) + + def test_missing_attribute_in_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.IN, None, ["admin"]) + + +# ── Segment operator tests ──────────────────────────────────────────────────── + + +class TestSegmentOperator: + def _make_evaluator(self, **segments: Segment) -> FlagEvaluator: + return FlagEvaluator(segments=segments) + + def _eval_segment(self, evaluator, context_key, segment_key, negate=False): + op = Operator.NOT_IN_SEGMENT if negate else Operator.IN_SEGMENT + clause = RuleClause(attribute="key", operator=op, values=[segment_key]) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key=context_key, attributes={"plan": "pro"}) + result = evaluator.evaluate(flag, ctx, {}) + return result.reason == EvaluationReason.RULE_MATCH + + def test_in_segment_via_included_list(self): + seg = Segment(key="beta", name="Beta", included=["user_1"]) + ev = self._make_evaluator(beta=seg) + assert self._eval_segment(ev, "user_1", "beta") + + def test_not_in_segment_excluded(self): + seg = Segment(key="beta", name="Beta", included=["user_1"], excluded=["user_1"]) + ev = self._make_evaluator(beta=seg) + # excluded overrides included + assert not self._eval_segment(ev, "user_1", "beta") + + def test_in_segment_via_rule(self): + seg_rule = SegmentRule( + clauses=[RuleClause(attribute="plan", operator=Operator.IS, values=["pro"])] + ) + seg = Segment(key="pro_users", name="Pro", rules=[seg_rule]) + ev = self._make_evaluator(pro_users=seg) + assert self._eval_segment(ev, "any_user", "pro_users") + + def test_not_in_segment_via_rule_no_match(self): + seg_rule = SegmentRule( + clauses=[RuleClause(attribute="plan", operator=Operator.IS, values=["pro"])] + ) + seg = Segment(key="pro_users", name="Pro", rules=[seg_rule]) + ev = self._make_evaluator(pro_users=seg) + # Context has plan=free, not pro → not in segment + clause = RuleClause(attribute="key", operator=Operator.IN_SEGMENT, values=["pro_users"]) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key="user_free", attributes={"plan": "free"}) + result = ev.evaluate(flag, ctx, {}) + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_missing_segment_logs_and_returns_false(self, caplog): + ev = FlagEvaluator(segments={}) + assert not self._eval_segment(ev, "user_1", "nonexistent_segment") + + def test_not_in_segment_operator(self): + seg = Segment(key="blocked", name="Blocked", included=["bad_user"]) + ev = self._make_evaluator(blocked=seg) + # good_user is NOT in blocked segment → NOT_IN_SEGMENT matches + assert self._eval_segment(ev, "good_user", "blocked", negate=True) + # bad_user IS in blocked segment → NOT_IN_SEGMENT does not match + assert not self._eval_segment(ev, "bad_user", "blocked", negate=True) + + +# ── Semver operator tests ───────────────────────────────────────────────────── + + +class TestSemverOperators: + def _eval(self, evaluator, op, actual, threshold): + clause = RuleClause(attribute="app_version", operator=op, values=[threshold]) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key="u", app_version=actual) + result = evaluator.evaluate(flag, ctx, {}) + return result.reason == EvaluationReason.RULE_MATCH + + def test_semver_eq(self, evaluator): + assert self._eval(evaluator, Operator.SEMVER_EQ, "2.3.1", "2.3.1") + + def test_semver_eq_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_EQ, "2.3.0", "2.3.1") + + def test_semver_lt(self, evaluator): + assert self._eval(evaluator, Operator.SEMVER_LT, "2.3.0", "2.3.1") + + def test_semver_lt_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_LT, "2.3.1", "2.3.0") + + def test_semver_gt(self, evaluator): + assert self._eval(evaluator, Operator.SEMVER_GT, "3.0.0", "2.9.9") + + def test_semver_gt_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_GT, "2.0.0", "2.9.9") + + def test_semver_invalid_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_GT, "not-a-version", "1.0.0") + + +# ── Rollout bucket stability ────────────────────────────────────────────────── + + +class TestRolloutBucketStability: + def test_bucket_is_deterministic(self): + ev = FlagEvaluator(segments={}) + flag = _flag( + fallthrough=[ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ] + ) + ctx = _ctx("fixed_key") + results = [ev.evaluate(flag, ctx, {}).variation for _ in range(20)] + assert len(set(results)) == 1 # always the same + + def test_different_flag_keys_different_buckets(self): + """Different flag keys produce different buckets for the same context.""" + ev = FlagEvaluator(segments={}) + ctx = _ctx("user_1") + rollout = [ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ] + flag_a = _flag("flag_a", fallthrough=rollout) + flag_b = _flag("flag_b", fallthrough=rollout) + results = { + ev.evaluate(flag_a, ctx, {}).variation, + ev.evaluate(flag_b, ctx, {}).variation, + } + # Not guaranteed to differ, but flag keys do affect the bucket + # so we just verify both evaluate without error + assert all(r in ("on", "off") for r in results) + + def test_weights_sum_100k_covers_all(self): + """100% weight on one variation → all contexts get it.""" + ev = FlagEvaluator(segments={}) + flag = _flag(fallthrough=[RolloutVariation(variation="on", weight=100_000)]) + for i in range(50): + r = ev.evaluate(flag, _ctx(f"user_{i}"), {}) + assert r.variation == "on" diff --git a/tests/core/feature_flags/test_models.py b/tests/core/feature_flags/test_models.py new file mode 100644 index 0000000..b075e6f --- /dev/null +++ b/tests/core/feature_flags/test_models.py @@ -0,0 +1,424 @@ +"""Tests for shield.core.feature_flags.models. + +All tests are sync and pure — no I/O, no async, no openfeature dependency. +Models are importable without the [flags] extra. +""" + +from __future__ import annotations + +from datetime import UTC + +import pytest +from pydantic import ValidationError + +from shield.core.feature_flags.models import ( + EvaluationContext, + EvaluationReason, + FeatureFlag, + FlagStatus, + FlagType, + FlagVariation, + Operator, + Prerequisite, + ResolutionDetails, + RolloutVariation, + RuleClause, + ScheduledChange, + ScheduledChangeAction, + Segment, + SegmentRule, + TargetingRule, +) + +# ── FlagType ───────────────────────────────────────────────────────────────── + + +class TestFlagType: + def test_all_values(self): + assert FlagType.BOOLEAN == "boolean" + assert FlagType.STRING == "string" + assert FlagType.INTEGER == "integer" + assert FlagType.FLOAT == "float" + assert FlagType.JSON == "json" + + +# ── FlagVariation ───────────────────────────────────────────────────────────── + + +class TestFlagVariation: + def test_boolean_variation(self): + v = FlagVariation(name="on", value=True) + assert v.name == "on" + assert v.value is True + assert v.description == "" + + def test_string_variation(self): + v = FlagVariation(name="blue", value="blue", description="Blue variant") + assert v.value == "blue" + assert v.description == "Blue variant" + + def test_json_variation(self): + v = FlagVariation(name="config", value={"limit": 100, "burst": 20}) + assert v.value == {"limit": 100, "burst": 20} + + def test_list_variation(self): + v = FlagVariation(name="tags", value=["a", "b"]) + assert v.value == ["a", "b"] + + +# ── RolloutVariation ────────────────────────────────────────────────────────── + + +class TestRolloutVariation: + def test_valid(self): + rv = RolloutVariation(variation="on", weight=25_000) + assert rv.variation == "on" + assert rv.weight == 25_000 + + def test_weight_zero(self): + rv = RolloutVariation(variation="off", weight=0) + assert rv.weight == 0 + + def test_weight_max(self): + rv = RolloutVariation(variation="on", weight=100_000) + assert rv.weight == 100_000 + + def test_weight_over_max_rejected(self): + with pytest.raises(ValidationError): + RolloutVariation(variation="on", weight=100_001) + + def test_weight_negative_rejected(self): + with pytest.raises(ValidationError): + RolloutVariation(variation="on", weight=-1) + + +# ── Operator ────────────────────────────────────────────────────────────────── + + +class TestOperator: + def test_all_operators_present(self): + expected = { + "is", + "is_not", + "contains", + "not_contains", + "starts_with", + "ends_with", + "matches", + "not_matches", + "gt", + "gte", + "lt", + "lte", + "before", + "after", + "in", + "not_in", + "in_segment", + "not_in_segment", + "semver_eq", + "semver_lt", + "semver_gt", + } + actual = {op.value for op in Operator} + assert actual == expected + + +# ── RuleClause ──────────────────────────────────────────────────────────────── + + +class TestRuleClause: + def test_basic(self): + clause = RuleClause(attribute="role", operator=Operator.IS, values=["admin"]) + assert clause.attribute == "role" + assert clause.operator == Operator.IS + assert clause.values == ["admin"] + assert clause.negate is False + + def test_negated(self): + clause = RuleClause(attribute="plan", operator=Operator.IN, values=["free"], negate=True) + assert clause.negate is True + + def test_multiple_values(self): + clause = RuleClause( + attribute="role", operator=Operator.IN, values=["admin", "moderator", "staff"] + ) + assert len(clause.values) == 3 + + +# ── TargetingRule ───────────────────────────────────────────────────────────── + + +class TestTargetingRule: + def test_auto_id(self): + rule = TargetingRule() + assert len(rule.id) == 36 # UUID4 format + + def test_with_fixed_variation(self): + rule = TargetingRule( + clauses=[RuleClause(attribute="role", operator=Operator.IS, values=["admin"])], + variation="on", + ) + assert rule.variation == "on" + assert rule.rollout is None + + def test_with_rollout(self): + rule = TargetingRule( + clauses=[], + rollout=[ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ], + ) + assert rule.variation is None + assert len(rule.rollout) == 2 + + def test_custom_id(self): + rule = TargetingRule(id="my-rule-id") + assert rule.id == "my-rule-id" + + +# ── Prerequisite ────────────────────────────────────────────────────────────── + + +class TestPrerequisite: + def test_basic(self): + prereq = Prerequisite(flag_key="auth_v2", variation="on") + assert prereq.flag_key == "auth_v2" + assert prereq.variation == "on" + + +# ── Segment ─────────────────────────────────────────────────────────────────── + + +class TestSegment: + def test_minimal(self): + seg = Segment(key="beta", name="Beta Users") + assert seg.key == "beta" + assert seg.included == [] + assert seg.excluded == [] + assert seg.rules == [] + + def test_with_members(self): + seg = Segment( + key="beta", + name="Beta", + included=["user_1", "user_2"], + excluded=["user_99"], + ) + assert "user_1" in seg.included + assert "user_99" in seg.excluded + + def test_with_rules(self): + rule = SegmentRule( + clauses=[RuleClause(attribute="plan", operator=Operator.IN, values=["pro"])] + ) + seg = Segment(key="pro_users", name="Pro Users", rules=[rule]) + assert len(seg.rules) == 1 + + +# ── ScheduledChange ─────────────────────────────────────────────────────────── + + +class TestScheduledChange: + def test_auto_id(self): + from datetime import datetime + + sc = ScheduledChange( + execute_at=datetime(2026, 4, 1, 9, 0, tzinfo=UTC), + action=ScheduledChangeAction.ENABLE, + ) + assert len(sc.id) == 36 + assert sc.action == ScheduledChangeAction.ENABLE + assert sc.created_by == "system" + + def test_all_actions(self): + assert ScheduledChangeAction.ENABLE == "enable" + assert ScheduledChangeAction.DISABLE == "disable" + assert ScheduledChangeAction.UPDATE_ROLLOUT == "update_rollout" + assert ScheduledChangeAction.ADD_RULE == "add_rule" + assert ScheduledChangeAction.DELETE_RULE == "delete_rule" + + +# ── FeatureFlag ─────────────────────────────────────────────────────────────── + + +def _make_boolean_flag(key: str = "my_flag", enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="My Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + enabled=enabled, + ) + + +class TestFeatureFlag: + def test_minimal_boolean_flag(self): + flag = _make_boolean_flag() + assert flag.key == "my_flag" + assert flag.type == FlagType.BOOLEAN + assert flag.enabled is True + assert flag.status == FlagStatus.ACTIVE + assert flag.temporary is True + + def test_get_variation_value_found(self): + flag = _make_boolean_flag() + assert flag.get_variation_value("on") is True + assert flag.get_variation_value("off") is False + + def test_get_variation_value_missing(self): + flag = _make_boolean_flag() + assert flag.get_variation_value("nonexistent") is None + + def test_variation_names(self): + flag = _make_boolean_flag() + assert flag.variation_names() == ["on", "off"] + + def test_with_rollout_fallthrough(self): + flag = FeatureFlag( + key="rollout_flag", + name="Rollout", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=[ + RolloutVariation(variation="on", weight=25_000), + RolloutVariation(variation="off", weight=75_000), + ], + ) + assert isinstance(flag.fallthrough, list) + assert len(flag.fallthrough) == 2 + + def test_with_prerequisites(self): + flag = _make_boolean_flag() + flag.prerequisites = [Prerequisite(flag_key="auth_v2", variation="on")] + assert len(flag.prerequisites) == 1 + + def test_with_targets(self): + flag = _make_boolean_flag() + flag.targets = {"on": ["user_1", "user_2"]} + assert "user_1" in flag.targets["on"] + + def test_disabled_flag(self): + flag = _make_boolean_flag(enabled=False) + assert flag.enabled is False + + +# ── EvaluationContext ───────────────────────────────────────────────────────── + + +class TestEvaluationContext: + def test_minimal(self): + ctx = EvaluationContext(key="user_123") + assert ctx.key == "user_123" + assert ctx.kind == "user" + assert ctx.attributes == {} + + def test_all_named_fields(self): + ctx = EvaluationContext( + key="user_1", + kind="user", + email="user@example.com", + ip="1.2.3.4", + country="US", + app_version="2.3.1", + ) + assert ctx.email == "user@example.com" + assert ctx.ip == "1.2.3.4" + assert ctx.country == "US" + assert ctx.app_version == "2.3.1" + + def test_all_attributes_merges_fields(self): + ctx = EvaluationContext( + key="user_1", + kind="user", + email="a@b.com", + country="UK", + attributes={"plan": "pro", "role": "admin"}, + ) + attrs = ctx.all_attributes() + assert attrs["key"] == "user_1" + assert attrs["kind"] == "user" + assert attrs["email"] == "a@b.com" + assert attrs["country"] == "UK" + assert attrs["plan"] == "pro" + assert attrs["role"] == "admin" + + def test_attributes_override_named_fields(self): + """attributes dict wins over named fields when keys collide.""" + ctx = EvaluationContext( + key="user_1", + country="US", + attributes={"country": "UK"}, # should win + ) + attrs = ctx.all_attributes() + assert attrs["country"] == "UK" + + def test_none_named_fields_excluded(self): + ctx = EvaluationContext(key="user_1") + attrs = ctx.all_attributes() + assert "email" not in attrs + assert "ip" not in attrs + assert "country" not in attrs + assert "app_version" not in attrs + + def test_custom_kind(self): + ctx = EvaluationContext(key="org_42", kind="organization") + assert ctx.kind == "organization" + assert ctx.all_attributes()["kind"] == "organization" + + +# ── ResolutionDetails ───────────────────────────────────────────────────────── + + +class TestResolutionDetails: + def test_rule_match(self): + r = ResolutionDetails( + value=True, + variation="on", + reason=EvaluationReason.RULE_MATCH, + rule_id="rule_abc", + ) + assert r.value is True + assert r.variation == "on" + assert r.reason == EvaluationReason.RULE_MATCH + assert r.rule_id == "rule_abc" + assert r.prerequisite_key is None + + def test_prerequisite_fail(self): + r = ResolutionDetails( + value=False, + variation="off", + reason=EvaluationReason.PREREQUISITE_FAIL, + prerequisite_key="auth_v2", + ) + assert r.prerequisite_key == "auth_v2" + + def test_error(self): + r = ResolutionDetails( + value=False, + reason=EvaluationReason.ERROR, + error_message="Provider timeout", + ) + assert r.error_message == "Provider timeout" + assert r.variation is None + + def test_all_reasons_valid(self): + reasons = list(EvaluationReason) + assert len(reasons) == 7 + assert EvaluationReason.OFF in reasons + assert EvaluationReason.FALLTHROUGH in reasons + assert EvaluationReason.TARGET_MATCH in reasons + assert EvaluationReason.RULE_MATCH in reasons + assert EvaluationReason.PREREQUISITE_FAIL in reasons + assert EvaluationReason.ERROR in reasons + assert EvaluationReason.DEFAULT in reasons diff --git a/tests/core/feature_flags/test_provider.py b/tests/core/feature_flags/test_provider.py new file mode 100644 index 0000000..98d75f6 --- /dev/null +++ b/tests/core/feature_flags/test_provider.py @@ -0,0 +1,529 @@ +"""Tests for shield.core.feature_flags.provider — ShieldOpenFeatureProvider.""" + +from __future__ import annotations + +from openfeature.flag_evaluation import Reason + +from shield.core.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Operator, + RuleClause, + Segment, + SegmentRule, + TargetingRule, +) +from shield.core.feature_flags.provider import ShieldOpenFeatureProvider + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +class _FakeBackend: + """Minimal in-memory backend stub — no real I/O.""" + + def __init__(self, flags=(), segments=()): + self._flags = list(flags) + self._segments = list(segments) + + async def load_all_flags(self): + return self._flags + + async def load_all_segments(self): + return self._segments + + +class _NoFlagBackend: + """Backend that doesn't implement flag storage (pre-Phase 3).""" + + async def load_all_flags(self): + raise AttributeError("not supported") + + async def load_all_segments(self): + raise AttributeError("not supported") + + +def _bool_flag(key="my_flag", enabled=True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="My Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + enabled=enabled, + ) + + +def _string_flag(key="color_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Color Flag", + type=FlagType.STRING, + variations=[ + FlagVariation(name="blue", value="blue"), + FlagVariation(name="red", value="red"), + ], + off_variation="blue", + fallthrough="red", + ) + + +def _int_flag(key="limit_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Limit Flag", + type=FlagType.INTEGER, + variations=[ + FlagVariation(name="low", value=10), + FlagVariation(name="high", value=100), + ], + off_variation="low", + fallthrough="high", + ) + + +def _float_flag(key="rate_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Rate Flag", + type=FlagType.FLOAT, + variations=[ + FlagVariation(name="low", value=0.1), + FlagVariation(name="high", value=0.9), + ], + off_variation="low", + fallthrough="high", + ) + + +def _object_flag(key="config_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Config Flag", + type=FlagType.JSON, + variations=[ + FlagVariation(name="default", value={"limit": 10}), + FlagVariation(name="premium", value={"limit": 100}), + ], + off_variation="default", + fallthrough="premium", + ) + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + + +class TestProviderMetadata: + def test_name(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + assert provider.get_metadata().name == "shield" + + def test_hooks_empty(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + assert provider.get_provider_hooks() == [] + + +# --------------------------------------------------------------------------- +# initialize — loads flags and segments +# --------------------------------------------------------------------------- + + +class TestInitialize: + async def test_loads_flags_and_segments(self): + flag = _bool_flag() + seg = Segment(key="beta", name="Beta") + backend = _FakeBackend(flags=[flag], segments=[seg]) + provider = ShieldOpenFeatureProvider(backend) + + await provider._load_all() + + assert "my_flag" in provider._flags + assert "beta" in provider._segments + + async def test_graceful_on_missing_backend_support(self): + provider = ShieldOpenFeatureProvider(_NoFlagBackend()) + # Should not raise — operate with empty caches + await provider._load_all() + assert provider._flags == {} + assert provider._segments == {} + + def test_shutdown_noop(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + provider.shutdown() # must not raise + + +# --------------------------------------------------------------------------- +# resolve_boolean_details +# --------------------------------------------------------------------------- + + +class TestResolveBooleanDetails: + async def test_flag_not_found_returns_default(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_boolean_details("missing", True) + assert result.value is True + assert result.error_code == "FLAG_NOT_FOUND" + assert result.reason == Reason.DEFAULT + + async def test_disabled_flag_returns_off_variation(self): + flag = _bool_flag(enabled=False) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + assert result.value is False + assert result.reason == Reason.DISABLED + + async def test_enabled_flag_fallthrough(self): + flag = FeatureFlag( + key="feat", + name="Feat", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("feat", False) + assert result.value is True + assert result.reason == Reason.DEFAULT + + async def test_type_coercion_fallback_on_mismatch(self): + # String flag evaluated as boolean — should return default + flag = _string_flag(key="color") + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("color", True) + # "red" cannot be cast to bool cleanly (it IS truthy in Python), + # so it returns bool("red") == True — coercion succeeds here. + assert isinstance(result.value, bool) + + +# --------------------------------------------------------------------------- +# resolve_string_details +# --------------------------------------------------------------------------- + + +class TestResolveStringDetails: + async def test_string_fallthrough(self): + flag = _string_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_string_details("color_flag", "default") + assert result.value == "red" + + async def test_string_missing_returns_default(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_string_details("missing", "fallback") + assert result.value == "fallback" + assert result.error_code == "FLAG_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# resolve_integer_details +# --------------------------------------------------------------------------- + + +class TestResolveIntegerDetails: + async def test_integer_fallthrough(self): + flag = _int_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_integer_details("limit_flag", 0) + assert result.value == 100 + + async def test_integer_missing(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_integer_details("nope", 42) + assert result.value == 42 + + +# --------------------------------------------------------------------------- +# resolve_float_details +# --------------------------------------------------------------------------- + + +class TestResolveFloatDetails: + async def test_float_fallthrough(self): + flag = _float_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_float_details("rate_flag", 0.0) + assert abs(result.value - 0.9) < 1e-9 + + async def test_float_coercion_from_int(self): + flag = _int_flag(key="int_flag") + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_float_details("int_flag", 0.0) + assert result.value == float(100) + + +# --------------------------------------------------------------------------- +# resolve_object_details +# --------------------------------------------------------------------------- + + +class TestResolveObjectDetails: + async def test_object_fallthrough(self): + flag = _object_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_object_details("config_flag", {}) + assert result.value == {"limit": 100} + + async def test_object_missing(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_object_details("nope", {"x": 1}) + assert result.value == {"x": 1} + + +# --------------------------------------------------------------------------- +# Targeting — individual targets +# --------------------------------------------------------------------------- + + +class TestTargeting: + async def test_individual_target_match(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + flag = FeatureFlag( + key="beta_flag", + name="Beta", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + targets={"on": ["user_1", "user_2"]}, + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_1") + result = provider.resolve_boolean_details("beta_flag", False, ctx) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH + + async def test_individual_target_miss(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + flag = FeatureFlag( + key="beta_flag", + name="Beta", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + targets={"on": ["user_1"]}, + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_99") + result = provider.resolve_boolean_details("beta_flag", False, ctx) + assert result.value is False # fallthrough + + +# --------------------------------------------------------------------------- +# Targeting rules +# --------------------------------------------------------------------------- + + +class TestTargetingRules: + async def test_rule_match_reason(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + flag = FeatureFlag( + key="admin_flag", + name="Admin Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + rules=[ + TargetingRule( + clauses=[ + RuleClause( + attribute="role", + operator=Operator.IS, + values=["admin"], + ) + ], + variation="on", + ) + ], + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_1", attributes={"role": "admin"}) + result = provider.resolve_boolean_details("admin_flag", False, ctx) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH + + +# --------------------------------------------------------------------------- +# flag_metadata +# --------------------------------------------------------------------------- + + +class TestFlagMetadata: + async def test_metadata_keys_present(self): + flag = _bool_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + # Metadata keys are only present when non-None; a FALLTHROUGH/DEFAULT + # evaluation produces no rule_id or prerequisite_key. + assert isinstance(result.flag_metadata, dict) + + +# --------------------------------------------------------------------------- +# Cache management (upsert / delete) +# --------------------------------------------------------------------------- + + +class TestCacheManagement: + def test_upsert_flag(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + flag = _bool_flag() + provider.upsert_flag(flag) + assert "my_flag" in provider._flags + + def test_delete_flag(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + flag = _bool_flag() + provider.upsert_flag(flag) + provider.delete_flag("my_flag") + assert "my_flag" not in provider._flags + + def test_delete_flag_missing_is_noop(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + provider.delete_flag("nonexistent") # must not raise + + def test_upsert_segment(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + seg = Segment(key="beta", name="Beta") + provider.upsert_segment(seg) + assert "beta" in provider._segments + + def test_delete_segment(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + seg = Segment(key="beta", name="Beta") + provider.upsert_segment(seg) + provider.delete_segment("beta") + assert "beta" not in provider._segments + + def test_delete_segment_missing_is_noop(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + provider.delete_segment("nonexistent") # must not raise + + +# --------------------------------------------------------------------------- +# Reason mapping +# --------------------------------------------------------------------------- + + +class TestReasonMapping: + async def test_off_reason_maps_to_disabled(self): + flag = _bool_flag(enabled=False) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + assert result.reason == Reason.DISABLED + + async def test_fallthrough_reason_maps_to_default(self): + flag = _bool_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + assert result.reason == Reason.DEFAULT + + async def test_segment_rule_maps_to_targeting_match(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + seg = Segment( + key="pro_users", + name="Pro", + rules=[ + SegmentRule( + clauses=[ + RuleClause( + attribute="plan", + operator=Operator.IS, + values=["pro"], + ) + ] + ) + ], + ) + flag = FeatureFlag( + key="pro_flag", + name="Pro Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + rules=[ + TargetingRule( + clauses=[ + RuleClause( + attribute="", + operator=Operator.IN_SEGMENT, + values=["pro_users"], + ) + ], + variation="on", + ) + ], + ) + backend = _FakeBackend(flags=[flag], segments=[seg]) + provider = ShieldOpenFeatureProvider(backend) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_1", attributes={"plan": "pro"}) + result = provider.resolve_boolean_details("pro_flag", False, ctx) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH diff --git a/tests/core/feature_flags/test_scheduler.py b/tests/core/feature_flags/test_scheduler.py new file mode 100644 index 0000000..7811d73 --- /dev/null +++ b/tests/core/feature_flags/test_scheduler.py @@ -0,0 +1,348 @@ +"""Tests for FlagScheduler — scheduled flag change runner.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Operator, + RuleClause, + ScheduledChange, + ScheduledChangeAction, + TargetingRule, +) +from shield.core.feature_flags.scheduler import FlagScheduler + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag(key: str, enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name=key.title(), + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + enabled=enabled, + ) + + +def _change( + action: ScheduledChangeAction, + delta_seconds: float = 0.05, + payload: dict | None = None, +) -> ScheduledChange: + return ScheduledChange( + execute_at=datetime.now(UTC) + timedelta(seconds=delta_seconds), + action=action, + payload=payload or {}, + ) + + +def _past_change(action: ScheduledChangeAction, payload: dict | None = None) -> ScheduledChange: + return ScheduledChange( + execute_at=datetime.now(UTC) - timedelta(hours=2), + action=action, + payload=payload or {}, + ) + + +# --------------------------------------------------------------------------- +# Basic construction / lifecycle +# --------------------------------------------------------------------------- + + +class TestFlagSchedulerLifecycle: + async def test_start_with_no_flags(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.start() # should not raise + await scheduler.stop() + + async def test_stop_with_no_tasks(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.stop() # idempotent + + async def test_list_pending_empty(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + assert scheduler.list_pending() == [] + + async def test_list_pending_after_schedule(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("pending-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + await scheduler.schedule("pending-flag", change) + pending = scheduler.list_pending() + assert len(pending) == 1 + assert pending[0]["flag_key"] == "pending-flag" + assert pending[0]["change_id"] == change.id + await scheduler.stop() + + async def test_cancel_removes_task(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("cancel-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + await scheduler.schedule("cancel-flag", change) + assert len(scheduler.list_pending()) == 1 + await scheduler.cancel("cancel-flag", change.id) + assert scheduler.list_pending() == [] + + async def test_cancel_nonexistent_is_noop(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.cancel("ghost-flag", "ghost-id") # should not raise + + async def test_cancel_all_for_flag(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("multi-change-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + c1 = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + c2 = _change(ScheduledChangeAction.ENABLE, delta_seconds=120) + await scheduler.schedule("multi-change-flag", c1) + await scheduler.schedule("multi-change-flag", c2) + assert len(scheduler.list_pending()) == 2 + await scheduler.cancel_all_for_flag("multi-change-flag") + assert scheduler.list_pending() == [] + + async def test_stop_cancels_all(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("stop-all-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + c1 = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + c2 = _change(ScheduledChangeAction.ENABLE, delta_seconds=120) + await scheduler.schedule("stop-all-flag", c1) + await scheduler.schedule("stop-all-flag", c2) + await scheduler.stop() + assert scheduler.list_pending() == [] + + +# --------------------------------------------------------------------------- +# Action execution — ENABLE +# --------------------------------------------------------------------------- + + +class TestScheduledEnable: + async def test_enable_action_fires(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("enable-me", enabled=False) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.ENABLE, delta_seconds=0.05) + await scheduler.schedule("enable-me", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("enable-me") + assert updated.enabled is True + + async def test_enable_removes_change_from_flag(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("rm-change-enable", enabled=False) + change = _change(ScheduledChangeAction.ENABLE, delta_seconds=0.05) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + await scheduler.schedule("rm-change-enable", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("rm-change-enable") + assert all(c.id != change.id for c in updated.scheduled_changes) + + +# --------------------------------------------------------------------------- +# Action execution — DISABLE +# --------------------------------------------------------------------------- + + +class TestScheduledDisable: + async def test_disable_action_fires(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("disable-me", enabled=True) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=0.05) + await scheduler.schedule("disable-me", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("disable-me") + assert updated.enabled is False + + +# --------------------------------------------------------------------------- +# Action execution — UPDATE_ROLLOUT +# --------------------------------------------------------------------------- + + +class TestScheduledUpdateRollout: + async def test_update_rollout_changes_fallthrough(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("rollout-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change( + ScheduledChangeAction.UPDATE_ROLLOUT, + delta_seconds=0.05, + payload={"variation": "off"}, + ) + await scheduler.schedule("rollout-flag", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("rollout-flag") + assert updated.fallthrough == "off" + + async def test_update_rollout_missing_payload_does_not_crash(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("rollout-flag2") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + # payload is empty — should log warning, not crash + change = _change(ScheduledChangeAction.UPDATE_ROLLOUT, delta_seconds=0.05, payload={}) + await scheduler.schedule("rollout-flag2", change) + await asyncio.sleep(0.3) + # Flag should still exist unchanged + still_there = await engine.get_flag("rollout-flag2") + assert still_there is not None + + +# --------------------------------------------------------------------------- +# Action execution — ADD_RULE / DELETE_RULE +# --------------------------------------------------------------------------- + + +class TestScheduledRuleMutations: + async def test_add_rule_appends(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("add-rule-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + rule_payload = { + "id": "r-new", + "clauses": [{"attribute": "email", "operator": "ends_with", "values": ["@acme.com"]}], + "variation": "on", + } + change = _change( + ScheduledChangeAction.ADD_RULE, + delta_seconds=0.05, + payload=rule_payload, + ) + await scheduler.schedule("add-rule-flag", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("add-rule-flag") + assert any(r.id == "r-new" for r in updated.rules) + + async def test_delete_rule_removes(self) -> None: + engine = ShieldEngine() + rule = TargetingRule( + id="r-del", + clauses=[RuleClause(attribute="role", operator=Operator.IN, values=["admin"])], + variation="on", + ) + flag = _bool_flag("del-rule-flag") + flag = flag.model_copy(update={"rules": [rule]}) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change( + ScheduledChangeAction.DELETE_RULE, + delta_seconds=0.05, + payload={"rule_id": "r-del"}, + ) + await scheduler.schedule("del-rule-flag", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("del-rule-flag") + assert all(r.id != "r-del" for r in updated.rules) + + +# --------------------------------------------------------------------------- +# Start — restart recovery from backend +# --------------------------------------------------------------------------- + + +class TestSchedulerStartRecovery: + async def test_start_schedules_future_changes(self) -> None: + engine = ShieldEngine() + change = _change(ScheduledChangeAction.ENABLE, delta_seconds=0.1) + flag = _bool_flag("recovery-flag", enabled=False) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + + scheduler = FlagScheduler(engine) + await scheduler.start() + assert len(scheduler.list_pending()) == 1 + await asyncio.sleep(0.4) + updated = await engine.get_flag("recovery-flag") + assert updated.enabled is True + await scheduler.stop() + + async def test_start_skips_past_changes(self) -> None: + engine = ShieldEngine() + change = _past_change(ScheduledChangeAction.ENABLE) + flag = _bool_flag("past-change-flag", enabled=False) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + + scheduler = FlagScheduler(engine) + await scheduler.start() + # Past changes don't get a task + assert scheduler.list_pending() == [] + await scheduler.stop() + + async def test_start_ignores_missing_flag(self) -> None: + """start() should not crash if a flag disappears between load and task run.""" + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.start() # no flags + await scheduler.stop() + + +# --------------------------------------------------------------------------- +# Engine integration — engine.start() wires FlagScheduler +# --------------------------------------------------------------------------- + + +class TestEngineIntegration: + async def test_engine_flag_scheduler_property(self) -> None: + engine = ShieldEngine() + assert engine.flag_scheduler is None + engine.use_openfeature() + assert engine.flag_scheduler is not None + + async def test_engine_start_starts_scheduler(self) -> None: + engine = ShieldEngine() + engine.use_openfeature() + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=0.1) + flag = _bool_flag("eng-sched-flag", enabled=True) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + await engine.start() + assert len(engine.flag_scheduler.list_pending()) == 1 + await asyncio.sleep(0.4) + updated = await engine.get_flag("eng-sched-flag") + assert updated.enabled is False + await engine.stop() + + async def test_engine_stop_stops_scheduler(self) -> None: + engine = ShieldEngine() + engine.use_openfeature() + await engine.start() + # Add a long-running task + flag = _bool_flag("stop-eng-flag") + await engine.save_flag(flag) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + await engine.flag_scheduler.schedule("stop-eng-flag", change) + assert len(engine.flag_scheduler.list_pending()) == 1 + await engine.stop() + assert engine.flag_scheduler.list_pending() == [] diff --git a/tests/core/feature_flags/test_sync_client.py b/tests/core/feature_flags/test_sync_client.py new file mode 100644 index 0000000..4536f79 --- /dev/null +++ b/tests/core/feature_flags/test_sync_client.py @@ -0,0 +1,222 @@ +"""Tests for _SyncShieldFeatureClient and engine.sync.flag_client. + +Verifies that all five evaluation methods work correctly from a +synchronous context (the way FastAPI runs ``def`` route handlers). +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("openfeature", reason="api-shield[flags] not installed") + +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.client import ShieldFeatureClient, _SyncShieldFeatureClient +from shield.core.feature_flags.models import FeatureFlag, FlagType, FlagVariation + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_engine() -> ShieldEngine: + engine = ShieldEngine() + engine.use_openfeature() + return engine + + +def _make_flag( + key: str, ftype: FlagType, variations: list[FlagVariation], fallthrough: str +) -> FeatureFlag: + return FeatureFlag( + key=key, + name=key, + type=ftype, + enabled=True, + variations=variations, + off_variation=variations[-1].name, + fallthrough=fallthrough, + ) + + +# --------------------------------------------------------------------------- +# _SyncShieldFeatureClient — unit +# --------------------------------------------------------------------------- + + +class TestSyncShieldFeatureClient: + def test_is_returned_by_flag_client_sync_property(self) -> None: + engine = _make_engine() + fc: ShieldFeatureClient = engine._flag_client + assert isinstance(fc.sync, _SyncShieldFeatureClient) + + def test_each_call_returns_fresh_instance(self) -> None: + engine = _make_engine() + fc: ShieldFeatureClient = engine._flag_client + # Two accesses to .sync return separate objects (not cached), + # but both wrap the same underlying OpenFeature client. + a = fc.sync + b = fc.sync + assert a._of_client is b._of_client + + def test_get_boolean_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_boolean_value("unknown_flag", True) + assert result is True + + def test_get_string_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_string_value("unknown_flag", "fallback") + assert result == "fallback" + + def test_get_integer_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_integer_value("unknown_flag", 42) + assert result == 42 + + def test_get_float_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_float_value("unknown_flag", 3.14) + assert result == pytest.approx(3.14) + + def test_get_object_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + default = {"k": "v"} + result = engine.sync.flag_client.get_object_value("unknown_flag", default) + assert result == default + + def test_ctx_dict_accepted(self) -> None: + """Passing a plain dict as ctx does not raise.""" + engine = _make_engine() + result = engine.sync.flag_client.get_boolean_value( + "unknown_flag", False, {"targeting_key": "user_123"} + ) + assert result is False + + def test_ctx_none_accepted(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_boolean_value("unknown_flag", True, None) + assert result is True + + +# --------------------------------------------------------------------------- +# engine.sync.flag_client integration +# --------------------------------------------------------------------------- + + +class TestEngineSyncFlagClient: + def test_returns_none_before_use_openfeature(self) -> None: + engine = ShieldEngine() + assert engine.sync.flag_client is None + + def test_returns_sync_client_after_use_openfeature(self) -> None: + engine = _make_engine() + fc = engine.sync.flag_client + assert fc is not None + assert isinstance(fc, _SyncShieldFeatureClient) + + def test_evaluates_registered_boolean_flag(self) -> None: + """A saved boolean flag returns its fallthrough value.""" + import asyncio + + engine = _make_engine() + + flag = _make_flag( + "beta_feature", + FlagType.BOOLEAN, + [FlagVariation(name="on", value=True), FlagVariation(name="off", value=False)], + "on", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_boolean_value("beta_feature", False) + assert result is True + + def test_evaluates_registered_string_flag(self) -> None: + import asyncio + + engine = _make_engine() + flag = _make_flag( + "theme", + FlagType.STRING, + [FlagVariation(name="dark", value="dark"), FlagVariation(name="light", value="light")], + "dark", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_string_value("theme", "light") + assert result == "dark" + + def test_evaluates_registered_integer_flag(self) -> None: + import asyncio + + engine = _make_engine() + flag = _make_flag( + "max_retries", + FlagType.INTEGER, + [FlagVariation(name="low", value=3), FlagVariation(name="high", value=10)], + "high", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_integer_value("max_retries", 1) + assert result == 10 + + def test_evaluates_registered_float_flag(self) -> None: + import asyncio + + engine = _make_engine() + flag = _make_flag( + "rate", + FlagType.FLOAT, + [FlagVariation(name="low", value=0.1), FlagVariation(name="high", value=0.9)], + "low", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_float_value("rate", 0.5) + assert result == pytest.approx(0.1) + + def test_disabled_flag_returns_default(self) -> None: + """A disabled flag always returns the default value.""" + import asyncio + + engine = _make_engine() + flag = FeatureFlag( + key="off_flag", + name="off_flag", + type=FlagType.BOOLEAN, + enabled=False, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_boolean_value("off_flag", False) + # Disabled flag → OpenFeature returns the OFF variation or default + assert isinstance(result, bool) + + def test_sync_and_async_return_same_value(self) -> None: + """Sync and async evaluation of the same flag return identical results.""" + import asyncio + + engine = _make_engine() + flag = _make_flag( + "consistent", + FlagType.BOOLEAN, + [FlagVariation(name="on", value=True), FlagVariation(name="off", value=False)], + "on", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + sync_result = engine.sync.flag_client.get_boolean_value("consistent", False) + + async def _async_eval() -> bool: + return await engine.flag_client.get_boolean_value("consistent", False) + + async_result = asyncio.get_event_loop().run_until_complete(_async_eval()) + assert sync_result == async_result 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_flag_dashboard.py b/tests/dashboard/test_flag_dashboard.py new file mode 100644 index 0000000..df3e511 --- /dev/null +++ b/tests/dashboard/test_flag_dashboard.py @@ -0,0 +1,910 @@ +"""Tests for the flag + segment dashboard UI routes (Phase 5).""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.admin.app import ShieldAdmin +from shield.core.engine import ShieldEngine + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def engine() -> ShieldEngine: + e = ShieldEngine() + await e.start() + yield e + await e.stop() + + +@pytest.fixture +def admin_app(engine: ShieldEngine) -> object: + """ShieldAdmin with flags enabled, no auth.""" + return ShieldAdmin(engine=engine, auth=None, enable_flags=True) + + +@pytest.fixture +async def client(admin_app: object) -> AsyncClient: + async with AsyncClient( + transport=ASGITransport(app=admin_app), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + yield c + + +@pytest.fixture +def admin_app_no_flags(engine: ShieldEngine) -> object: + """ShieldAdmin with flags disabled.""" + return ShieldAdmin(engine=engine, auth=None, enable_flags=False) + + +@pytest.fixture +async def client_no_flags(admin_app_no_flags: object) -> AsyncClient: + async with AsyncClient( + transport=ASGITransport(app=admin_app_no_flags), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _flag_payload( + key: str = "my-flag", + flag_type: str = "boolean", + enabled: bool = True, + on_value: object = True, + off_value: object = False, +) -> dict: + return { + "key": key, + "name": key.replace("-", " ").title(), + "type": flag_type, + "variations": [ + {"name": "on", "value": on_value}, + {"name": "off", "value": off_value}, + ], + "off_variation": "off", + "fallthrough": "on", + "enabled": enabled, + } + + +def _segment_payload( + key: str, included: list[str] | None = None, excluded: list[str] | None = None +) -> dict: + return { + "key": key, + "name": key.replace("-", " ").title(), + "included": included or [], + "excluded": excluded or [], + "rules": [], + } + + +# --------------------------------------------------------------------------- +# Flags page — GET /flags +# --------------------------------------------------------------------------- + + +class TestFlagsPage: + async def test_flags_page_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/flags") + assert resp.status_code == 200 + + async def test_flags_page_html(self, client: AsyncClient) -> None: + resp = await client.get("/flags") + assert "text/html" in resp.headers["content-type"] + + async def test_flags_page_shows_empty_state(self, client: AsyncClient) -> None: + resp = await client.get("/flags") + # Empty flags list renders without error + assert resp.status_code == 200 + + async def test_flags_page_shows_flag_key( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("checkout-flag")) + resp = await client.get("/flags") + assert "checkout-flag" in resp.text + + async def test_flags_page_not_mounted_when_disabled(self, client_no_flags: AsyncClient) -> None: + resp = await client_no_flags.get("/flags") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flags rows partial — GET /flags/rows +# --------------------------------------------------------------------------- + + +class TestFlagsRowsPartial: + async def test_rows_partial_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/flags/rows") + assert resp.status_code == 200 + + async def test_rows_partial_search_filter(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("alpha-flag")) + await client.post("/api/flags", json=_flag_payload("beta-flag")) + resp = await client.get("/flags/rows?q=alpha") + assert "alpha-flag" in resp.text + assert "beta-flag" not in resp.text + + async def test_rows_partial_type_filter(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("bool-flag", "boolean")) + await client.post( + "/api/flags", + json=_flag_payload("str-flag", "string", on_value="hello", off_value=""), + ) + resp = await client.get("/flags/rows?type=boolean") + assert "bool-flag" in resp.text + assert "str-flag" not in resp.text + + async def test_rows_partial_status_enabled(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("on-flag", enabled=True)) + await client.post("/api/flags", json=_flag_payload("off-flag", enabled=False)) + resp = await client.get("/flags/rows?status=enabled") + assert "on-flag" in resp.text + assert "off-flag" not in resp.text + + async def test_rows_partial_status_disabled(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("on-flag", enabled=True)) + await client.post("/api/flags", json=_flag_payload("off-flag", enabled=False)) + resp = await client.get("/flags/rows?status=disabled") + assert "off-flag" in resp.text + assert "on-flag" not in resp.text + + +# --------------------------------------------------------------------------- +# Flag detail page — GET /flags/{key} +# --------------------------------------------------------------------------- + + +class TestFlagDetailPage: + async def test_detail_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("detail-flag")) + resp = await client.get("/flags/detail-flag") + assert resp.status_code == 200 + + async def test_detail_shows_flag_key(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("detail-flag")) + resp = await client.get("/flags/detail-flag") + assert "detail-flag" in resp.text + + async def test_detail_404_for_missing_flag(self, client: AsyncClient) -> None: + resp = await client.get("/flags/nonexistent-flag") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flag enable / disable — POST /flags/{key}/enable, /flags/{key}/disable +# --------------------------------------------------------------------------- + + +class TestFlagEnableDisable: + async def test_flag_enable_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=False)) + resp = await client.post("/flags/toggle-flag/enable") + assert resp.status_code == 200 + + async def test_flag_enable_updates_state(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=False)) + await client.post("/flags/toggle-flag/enable") + check = await client.get("/api/flags/toggle-flag") + assert check.json()["enabled"] is True + + async def test_flag_disable_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=True)) + resp = await client.post("/flags/toggle-flag/disable") + assert resp.status_code == 200 + + async def test_flag_disable_updates_state(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=True)) + await client.post("/flags/toggle-flag/disable") + check = await client.get("/api/flags/toggle-flag") + assert check.json()["enabled"] is False + + async def test_enable_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post("/flags/ghost-flag/enable") + assert resp.status_code == 404 + + async def test_disable_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post("/flags/ghost-flag/disable") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flag delete — DELETE /flags/{key} +# --------------------------------------------------------------------------- + + +class TestFlagDelete: + async def test_flag_delete_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("del-flag")) + resp = await client.delete("/flags/del-flag") + assert resp.status_code == 200 + + async def test_flag_delete_removes_flag(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("del-flag")) + await client.delete("/flags/del-flag") + check = await client.get("/api/flags/del-flag") + assert check.status_code == 404 + + async def test_delete_missing_flag_returns_200(self, client: AsyncClient) -> None: + # Dashboard DELETE is idempotent — HTMX removes row; no 404 from UI + resp = await client.delete("/flags/ghost-flag") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Flag create modal — GET /modal/flag/create +# --------------------------------------------------------------------------- + + +class TestFlagCreateModal: + async def test_modal_create_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/modal/flag/create") + assert resp.status_code == 200 + + async def test_modal_create_contains_form(self, client: AsyncClient) -> None: + resp = await client.get("/modal/flag/create") + assert "form" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# Flag create form — POST /flags/create +# --------------------------------------------------------------------------- + + +class TestFlagCreateForm: + async def test_create_form_boolean_flag(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/create", + data={"key": "form-bool-flag", "type": "boolean", "name": "Form Bool"}, + ) + assert resp.status_code == 200 + + async def test_create_form_persists_flag(self, client: AsyncClient) -> None: + await client.post( + "/flags/create", + data={"key": "persisted-flag", "type": "boolean", "name": "Persisted"}, + ) + check = await client.get("/api/flags/persisted-flag") + assert check.status_code == 200 + assert check.json()["key"] == "persisted-flag" + + async def test_create_form_string_flag(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/create", + data={ + "key": "str-flag", + "type": "string", + "name": "String Flag", + "default_value": "hello", + }, + ) + assert resp.status_code == 200 + + async def test_create_form_duplicate_key_returns_error(self, client: AsyncClient) -> None: + await client.post( + "/flags/create", data={"key": "dup-flag", "type": "boolean", "name": "Dup"} + ) + resp = await client.post( + "/flags/create", data={"key": "dup-flag", "type": "boolean", "name": "Dup"} + ) + # Should return an error response (409 or HTML with error) + assert resp.status_code in (200, 409) + + +# --------------------------------------------------------------------------- +# Flag eval modal — GET /modal/flag/{key}/eval +# --------------------------------------------------------------------------- + + +class TestFlagEvalModal: + async def test_eval_modal_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag")) + resp = await client.get("/modal/flag/eval-flag/eval") + assert resp.status_code == 200 + + async def test_eval_modal_contains_form(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag")) + resp = await client.get("/modal/flag/eval-flag/eval") + assert "form" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# Flag eval form — POST /flags/{key}/eval +# --------------------------------------------------------------------------- + + +class TestFlagEvalForm: + async def test_eval_form_returns_result(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"targeting_key": "user-1"}, + ) + assert resp.status_code == 200 + + async def test_eval_form_triggers_event(self, client: AsyncClient) -> None: + import json as _json + + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"context_key": "user-1"}, + ) + assert resp.status_code == 200 + # Eval returns rich result HTML + HX-Trigger header with the result payload + assert "HX-Trigger" in resp.headers + trigger = _json.loads(resp.headers["HX-Trigger"]) + assert "shieldEvalDone" in trigger + payload = trigger["shieldEvalDone"] + assert "value" in payload + assert "reason" in payload + assert payload["flagKey"] == "eval-flag" + + async def test_eval_form_shows_result_panel(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"context_key": "user-1"}, + ) + assert resp.status_code == 200 + assert "Evaluation Result" in resp.text + assert "FALLTHROUGH" in resp.text or "OFF" in resp.text or "RULE_MATCH" in resp.text + + async def test_eval_form_shows_context_summary(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"context_key": "user-99", "kind": "organization", "attributes": "plan=pro"}, + ) + assert resp.status_code == 200 + assert "user-99" in resp.text + assert "organization" in resp.text + assert "plan" in resp.text + + +# --------------------------------------------------------------------------- +# Segments page — GET /segments +# --------------------------------------------------------------------------- + + +class TestSegmentsPage: + async def test_segments_page_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/segments") + assert resp.status_code == 200 + + async def test_segments_page_html(self, client: AsyncClient) -> None: + resp = await client.get("/segments") + assert "text/html" in resp.headers["content-type"] + + async def test_segments_page_not_mounted_when_disabled( + self, client_no_flags: AsyncClient + ) -> None: + resp = await client_no_flags.get("/segments") + assert resp.status_code == 404 + + async def test_segments_page_shows_segment(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("beta-users", included=["u1"])) + resp = await client.get("/segments") + assert "beta-users" in resp.text + + +# --------------------------------------------------------------------------- +# Segments rows partial — GET /segments/rows +# --------------------------------------------------------------------------- + + +class TestSegmentsRowsPartial: + async def test_rows_partial_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/segments/rows") + assert resp.status_code == 200 + + async def test_rows_partial_search_filter(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("alpha-seg")) + await client.post("/api/segments", json=_segment_payload("beta-seg")) + resp = await client.get("/segments/rows?q=alpha") + assert "alpha-seg" in resp.text + assert "beta-seg" not in resp.text + + +# --------------------------------------------------------------------------- +# Segment create modal — GET /modal/segment/create +# --------------------------------------------------------------------------- + + +class TestSegmentCreateModal: + async def test_modal_create_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/modal/segment/create") + assert resp.status_code == 200 + + async def test_modal_create_contains_form(self, client: AsyncClient) -> None: + resp = await client.get("/modal/segment/create") + assert "form" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# Segment create form — POST /segments/create +# --------------------------------------------------------------------------- + + +class TestSegmentCreateForm: + async def test_create_form_persists_segment(self, client: AsyncClient) -> None: + resp = await client.post( + "/segments/create", + data={"key": "new-seg", "name": "New Seg", "included": "u1\nu2", "excluded": ""}, + ) + assert resp.status_code == 200 + check = await client.get("/api/segments/new-seg") + assert check.status_code == 200 + assert check.json()["key"] == "new-seg" + + async def test_create_form_creates_empty_segment(self, client: AsyncClient) -> None: + # segment_create_form only sets key+name; membership is edited via save_form + await client.post( + "/segments/create", + data={"key": "inc-seg", "name": "Inc Seg"}, + ) + check = await client.get("/api/segments/inc-seg") + assert check.status_code == 200 + assert check.json()["key"] == "inc-seg" + + +# --------------------------------------------------------------------------- +# Segment detail modal — GET /modal/segment/{key} +# --------------------------------------------------------------------------- + + +class TestSegmentDetailModal: + async def test_detail_modal_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("detail-seg", included=["u1"])) + resp = await client.get("/modal/segment/detail-seg") + assert resp.status_code == 200 + + async def test_detail_modal_shows_key(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("detail-seg", included=["u1"])) + resp = await client.get("/modal/segment/detail-seg") + assert "detail-seg" in resp.text + + +# --------------------------------------------------------------------------- +# Segment save form — POST /segments/{key}/save +# --------------------------------------------------------------------------- + + +class TestSegmentSaveForm: + async def test_save_form_updates_included(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("save-seg", included=["old-user"])) + resp = await client.post( + "/segments/save-seg/save", + data={"included": "new-user1\nnew-user2", "excluded": ""}, + ) + assert resp.status_code == 200 + check = await client.get("/api/segments/save-seg") + assert "new-user1" in check.json()["included"] + assert "new-user2" in check.json()["included"] + + async def test_save_form_updates_excluded(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("save-seg2")) + resp = await client.post( + "/segments/save-seg2/save", + data={"included": "", "excluded": "blocked-user"}, + ) + assert resp.status_code == 200 + check = await client.get("/api/segments/save-seg2") + assert "blocked-user" in check.json()["excluded"] + + async def test_save_missing_segment_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/segments/ghost-seg/save", + data={"included": "u1", "excluded": ""}, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Segment delete — DELETE /segments/{key} +# --------------------------------------------------------------------------- + + +class TestSegmentDelete: + async def test_delete_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/segments", json={"key": "del-seg", "included": []}) + resp = await client.delete("/segments/del-seg") + assert resp.status_code == 200 + + async def test_delete_removes_segment(self, client: AsyncClient) -> None: + await client.post("/api/segments", json={"key": "del-seg2", "included": []}) + await client.delete("/segments/del-seg2") + check = await client.get("/api/segments/del-seg2") + assert check.status_code == 404 + + async def test_delete_missing_segment_returns_200(self, client: AsyncClient) -> None: + # Dashboard DELETE is idempotent — HTMX removes row; no 404 from UI + resp = await client.delete("/segments/ghost-seg") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# flags_enabled Jinja2 global +# --------------------------------------------------------------------------- + + +class TestFlagsEnabledGlobal: + async def test_index_no_flags_tab_when_disabled(self, client_no_flags: AsyncClient) -> None: + """When enable_flags=False, the main dashboard should not show flag nav links.""" + resp = await client_no_flags.get("/") + assert resp.status_code == 200 + # Flags nav item should not appear + assert "/flags" not in resp.text + + async def test_index_flags_tab_when_enabled(self, client: AsyncClient) -> None: + """When enable_flags=True, the main dashboard should show flag nav links.""" + resp = await client.get("/") + assert resp.status_code == 200 + assert "/flags" in resp.text + + +# --------------------------------------------------------------------------- +# Flag settings save — POST /flags/{key}/settings/save +# --------------------------------------------------------------------------- + + +class TestFlagSettingsSave: + async def test_settings_save_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + resp = await client.post( + "/flags/s-flag/settings/save", + data={"name": "New Name", "description": "Updated desc"}, + ) + assert resp.status_code == 200 + + async def test_settings_save_updates_name( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + await client.post( + "/flags/s-flag/settings/save", + data={"name": "Renamed Flag", "description": ""}, + ) + flag = await engine.get_flag("s-flag") + assert flag.name == "Renamed Flag" + + async def test_settings_save_updates_description( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + await client.post( + "/flags/s-flag/settings/save", + data={"name": "S Flag", "description": "A description"}, + ) + flag = await engine.get_flag("s-flag") + assert flag.description == "A description" + + async def test_settings_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/settings/save", + data={"name": "x", "description": ""}, + ) + assert resp.status_code == 404 + + async def test_settings_save_returns_hx_trigger(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + resp = await client.post( + "/flags/s-flag/settings/save", + data={"name": "x", "description": ""}, + ) + assert "HX-Trigger" in resp.headers + + +# --------------------------------------------------------------------------- +# Flag variations save — POST /flags/{key}/variations/save +# --------------------------------------------------------------------------- + + +class TestFlagVariationsSave: + async def test_variations_save_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + resp = await client.post( + "/flags/v-flag/variations/save", + data={ + "variations[0][name]": "enabled", + "variations[0][value]": "true", + "variations[1][name]": "disabled", + "variations[1][value]": "false", + }, + ) + assert resp.status_code == 200 + + async def test_variations_save_updates_names( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + await client.post( + "/flags/v-flag/variations/save", + data={ + "variations[0][name]": "enabled", + "variations[0][value]": "true", + "variations[1][name]": "disabled", + "variations[1][value]": "false", + }, + ) + flag = await engine.get_flag("v-flag") + names = [v.name for v in flag.variations] + assert "enabled" in names + assert "disabled" in names + + async def test_variations_save_fewer_than_two_returns_400(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + resp = await client.post( + "/flags/v-flag/variations/save", + data={"variations[0][name]": "only", "variations[0][value]": "true"}, + ) + assert resp.status_code == 400 + + async def test_variations_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/variations/save", + data={ + "variations[0][name]": "a", + "variations[0][value]": "true", + "variations[1][name]": "b", + "variations[1][value]": "false", + }, + ) + assert resp.status_code == 404 + + async def test_variations_save_returns_hx_trigger(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + resp = await client.post( + "/flags/v-flag/variations/save", + data={ + "variations[0][name]": "on", + "variations[0][value]": "true", + "variations[1][name]": "off", + "variations[1][value]": "false", + }, + ) + assert "HX-Trigger" in resp.headers + + +# --------------------------------------------------------------------------- +# Flag targeting save — POST /flags/{key}/targeting/save +# --------------------------------------------------------------------------- + + +class TestFlagTargetingSave: + async def test_targeting_save_off_variation( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"off_variation": "on"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert flag.off_variation == "on" + + async def test_targeting_save_fallthrough( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"fallthrough": "off"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert flag.fallthrough == "off" + + async def test_targeting_save_invalid_variation_returns_400(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"off_variation": "nonexistent"}, + ) + assert resp.status_code == 400 + + async def test_targeting_save_with_rule( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={ + "rules[0][id]": "", + "rules[0][description]": "Beta rule", + "rules[0][variation]": "on", + "rules[0][clauses][0][attribute]": "plan", + "rules[0][clauses][0][operator]": "is", + "rules[0][clauses][0][values]": "pro", + }, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].attribute == "plan" + + async def test_targeting_save_with_segment_rule( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """in_segment clauses should auto-set attribute to 'key' even when omitted.""" + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={ + "rules[0][id]": "", + "rules[0][description]": "Segment rule", + "rules[0][variation]": "on", + # attribute intentionally blank (hidden in dashboard for segment ops) + "rules[0][clauses][0][attribute]": "", + "rules[0][clauses][0][operator]": "in_segment", + "rules[0][clauses][0][values]": "beta-users", + }, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert len(flag.rules) == 1 + clause = flag.rules[0].clauses[0] + assert clause.operator == "in_segment" + assert clause.attribute == "key" + assert clause.values == ["beta-users"] + + async def test_targeting_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/targeting/save", + data={"off_variation": "on"}, + ) + assert resp.status_code == 404 + + async def test_targeting_save_returns_hx_trigger(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"off_variation": "on"}, + ) + assert "HX-Trigger" in resp.headers + + +# --------------------------------------------------------------------------- +# Flag prerequisites save — POST /flags/{key}/prerequisites/save +# --------------------------------------------------------------------------- + + +class TestFlagPrerequisitesSave: + async def test_prerequisites_save_empty( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST empty form clears prerequisites, returns 200.""" + await client.post("/api/flags", json=_flag_payload("prereq-flag")) + resp = await client.post("/flags/prereq-flag/prerequisites/save", data={}) + assert resp.status_code == 200 + flag = await engine.get_flag("prereq-flag") + assert flag.prerequisites == [] + + async def test_prerequisites_save_adds_prereq( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST with prereqs[0][flag_key]=other_flag&prereqs[0][variation]=on saves it, persists.""" + await client.post("/api/flags", json=_flag_payload("main-flag")) + await client.post("/api/flags", json=_flag_payload("other-flag")) + resp = await client.post( + "/flags/main-flag/prerequisites/save", + data={ + "prereqs[0][flag_key]": "other-flag", + "prereqs[0][variation]": "on", + }, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("main-flag") + assert len(flag.prerequisites) == 1 + assert flag.prerequisites[0].flag_key == "other-flag" + assert flag.prerequisites[0].variation == "on" + + async def test_prerequisites_save_circular_returns_400(self, client: AsyncClient) -> None: + """POST where flag_key == current flag's key returns 400.""" + await client.post("/api/flags", json=_flag_payload("circ-flag")) + resp = await client.post( + "/flags/circ-flag/prerequisites/save", + data={ + "prereqs[0][flag_key]": "circ-flag", + "prereqs[0][variation]": "on", + }, + ) + assert resp.status_code == 400 + + async def test_prerequisites_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/prerequisites/save", + data={}, + ) + assert resp.status_code == 404 + + async def test_prerequisites_save_returns_hx_trigger(self, client: AsyncClient) -> None: + """Response has HX-Trigger header with flagPrerequisitesSaved.""" + import json as _json + + await client.post("/api/flags", json=_flag_payload("hxt-flag")) + resp = await client.post("/flags/hxt-flag/prerequisites/save", data={}) + assert "HX-Trigger" in resp.headers + trigger = _json.loads(resp.headers["HX-Trigger"]) + assert "flagPrerequisitesSaved" in trigger + + +# --------------------------------------------------------------------------- +# Flag targets save — POST /flags/{key}/targets/save +# --------------------------------------------------------------------------- + + +class TestFlagTargetsSave: + async def test_targets_save_adds_keys(self, client: AsyncClient, engine: ShieldEngine) -> None: + """POST with targets[on]=user_123\\nuser_456, persists correctly.""" + await client.post("/api/flags", json=_flag_payload("tgt-flag")) + resp = await client.post( + "/flags/tgt-flag/targets/save", + data={"targets[on]": "user_123\nuser_456"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("tgt-flag") + assert "user_123" in flag.targets.get("on", []) + assert "user_456" in flag.targets.get("on", []) + + async def test_targets_save_clears_targets( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST with empty textareas clears targets.""" + await client.post( + "/api/flags", + json={**_flag_payload("tgt-clear-flag"), "targets": {"on": ["old-user"]}}, + ) + resp = await client.post( + "/flags/tgt-clear-flag/targets/save", + data={"targets[on]": "", "targets[off]": ""}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("tgt-clear-flag") + assert flag.targets == {} or flag.targets.get("on", []) == [] + + async def test_targets_save_ignores_unknown_variation( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST with targets[nonexistent]=user_x doesn't save it.""" + await client.post("/api/flags", json=_flag_payload("tgt-unk-flag")) + resp = await client.post( + "/flags/tgt-unk-flag/targets/save", + data={"targets[nonexistent]": "user_x"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("tgt-unk-flag") + assert "nonexistent" not in flag.targets + + async def test_targets_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/targets/save", + data={"targets[on]": "user_1"}, + ) + assert resp.status_code == 404 + + async def test_targets_save_returns_hx_trigger(self, client: AsyncClient) -> None: + """Response has HX-Trigger header with flagTargetsSaved.""" + import json as _json + + await client.post("/api/flags", json=_flag_payload("tgt-hxt-flag")) + resp = await client.post( + "/flags/tgt-hxt-flag/targets/save", + data={"targets[on]": "user_1"}, + ) + assert "HX-Trigger" in resp.headers + trigger = _json.loads(resp.headers["HX-Trigger"]) + assert "flagTargetsSaved" in trigger 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/dashboard/test_routes.py b/tests/dashboard/test_routes.py index 17f9fc5..d1dbf6d 100644 --- a/tests/dashboard/test_routes.py +++ b/tests/dashboard/test_routes.py @@ -320,8 +320,11 @@ async def test_sse_keepalive_when_subscribe_unsupported() -> None: request = Request(scope) response = await r.events(request) - # Patch anyio.sleep to return immediately so the keepalive fires instantly. - with unittest.mock.patch("shield.dashboard.routes.anyio.sleep", return_value=None): + # Patch anyio.sleep to return immediately and simulate connected client. + with ( + unittest.mock.patch("shield.dashboard.routes.anyio.sleep", return_value=None), + unittest.mock.patch.object(request, "is_disconnected", return_value=False), + ): gen = response.body_iterator # type: ignore[union-attr] first_chunk = await asyncio.wait_for(gen.__anext__(), timeout=2.0) # type: ignore[union-attr] diff --git a/tests/fastapi/_helpers.py b/tests/fastapi/_helpers.py new file mode 100644 index 0000000..7617040 --- /dev/null +++ b/tests/fastapi/_helpers.py @@ -0,0 +1,21 @@ +"""Shared test helpers for the FastAPI test suite.""" + +from __future__ import annotations + +import asyncio + +from fastapi import FastAPI + + +async def _trigger_startup(app: FastAPI) -> None: + """Fire all on_startup handlers registered on ``app.router``. + + ``starlette.Router.startup()`` was removed in Starlette 0.41+ in favour + of the lifespan protocol. This helper iterates the handlers directly so + tests remain compatible across all supported Starlette versions. + """ + for handler in app.router.on_startup: + if asyncio.iscoroutinefunction(handler): + await handler() + else: + handler() diff --git a/tests/fastapi/test_acceptance.py b/tests/fastapi/test_acceptance.py index fc2c228..dc276d0 100644 --- a/tests/fastapi/test_acceptance.py +++ b/tests/fastapi/test_acceptance.py @@ -24,6 +24,7 @@ force_active, maintenance, ) +from tests.fastapi._helpers import _trigger_startup @pytest.fixture @@ -59,7 +60,7 @@ async def health(): app.include_router(router) apply_shield_to_openapi(app, engine) - await app.router.startup() + await _trigger_startup(app) return app, engine diff --git a/tests/fastapi/test_deprecated.py b/tests/fastapi/test_deprecated.py index 1aa28fe..9166395 100644 --- a/tests/fastapi/test_deprecated.py +++ b/tests/fastapi/test_deprecated.py @@ -11,6 +11,7 @@ from shield.fastapi.decorators import deprecated from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build_app(env: str = "dev") -> tuple[FastAPI, ShieldEngine]: @@ -82,7 +83,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) # @router.get() registers "GET:/v1/users" (method-specific key) state = await engine.backend.get_state("GET:/v1/users") @@ -106,7 +107,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -124,7 +125,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -142,7 +143,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -160,7 +161,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -180,7 +181,7 @@ async def v1_items(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/items") @@ -220,7 +221,7 @@ async def v1_orders(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() diff --git a/tests/fastapi/test_global_maintenance.py b/tests/fastapi/test_global_maintenance.py index ed4f36a..59b3ce9 100644 --- a/tests/fastapi/test_global_maintenance.py +++ b/tests/fastapi/test_global_maintenance.py @@ -16,6 +16,7 @@ from shield.fastapi.decorators import disabled, force_active from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _app_with_routes() -> tuple[FastAPI, ShieldEngine]: @@ -115,7 +116,7 @@ async def test_global_maintenance_blocks_normal_routes(): async def test_global_maintenance_respects_force_active_by_default(): """@force_active routes must remain reachable when include_force_active=False.""" app, engine = _app_with_routes() - await app.router.startup() + await _trigger_startup(app) await engine.enable_global_maintenance(reason="System upgrade") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: @@ -127,7 +128,7 @@ async def test_global_maintenance_respects_force_active_by_default(): async def test_global_maintenance_overrides_force_active_when_flag_set(): """When include_force_active=True, even @force_active routes return 503.""" app, engine = _app_with_routes() - await app.router.startup() + await _trigger_startup(app) await engine.enable_global_maintenance(reason="Hard lockdown", include_force_active=True) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: diff --git a/tests/fastapi/test_global_maintenance_docs.py b/tests/fastapi/test_global_maintenance_docs.py index 770b31a..9033b09 100644 --- a/tests/fastapi/test_global_maintenance_docs.py +++ b/tests/fastapi/test_global_maintenance_docs.py @@ -11,6 +11,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi, setup_shield_docs from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build() -> tuple[FastAPI, ShieldEngine, ShieldRouter]: @@ -34,7 +35,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -50,7 +51,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Deploy window") @@ -70,7 +71,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Emergency patch") @@ -98,7 +99,7 @@ async def health(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Upgrade", exempt_paths=["/health"]) @@ -124,7 +125,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Global reason") @@ -142,7 +143,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Temp") diff --git a/tests/fastapi/test_middleware.py b/tests/fastapi/test_middleware.py index 2c4860f..8e807d9 100644 --- a/tests/fastapi/test_middleware.py +++ b/tests/fastapi/test_middleware.py @@ -18,6 +18,7 @@ from shield.fastapi.decorators import disabled, env_only, force_active, maintenance from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build_app(env: str = "dev") -> tuple[FastAPI, ShieldEngine]: @@ -34,7 +35,7 @@ def _include(app: FastAPI, router: ShieldRouter) -> None: async def _startup(app: FastAPI) -> None: - await app.router.startup() + await _trigger_startup(app) # --------------------------------------------------------------------------- diff --git a/tests/fastapi/test_openapi.py b/tests/fastapi/test_openapi.py index 4a1d24e..f4a9460 100644 --- a/tests/fastapi/test_openapi.py +++ b/tests/fastapi/test_openapi.py @@ -10,6 +10,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _make_full_app(env: str = "dev"): @@ -38,7 +39,7 @@ async def new(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -64,7 +65,7 @@ async def health(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -81,7 +82,7 @@ async def debug(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -130,7 +131,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -181,7 +182,7 @@ async def new(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) # First call — /old is disabled and should be hidden. diff --git a/tests/fastapi/test_openapi_maintenance.py b/tests/fastapi/test_openapi_maintenance.py index 73d82e6..fca3f7c 100644 --- a/tests/fastapi/test_openapi_maintenance.py +++ b/tests/fastapi/test_openapi_maintenance.py @@ -12,6 +12,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi, setup_shield_docs from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build(env: str = "dev") -> tuple[FastAPI, ShieldEngine, ShieldRouter]: @@ -36,7 +37,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -54,7 +55,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -74,7 +75,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -93,7 +94,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) _ = app.openapi() @@ -112,7 +113,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -129,7 +130,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -153,7 +154,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) setup_shield_docs(app, engine) @@ -173,7 +174,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) setup_shield_docs(app, engine) @@ -212,7 +213,7 @@ async def health(): return {"status": "ok"} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) setup_shield_docs(app, engine) diff --git a/tests/fastapi/test_parameterized_routes.py b/tests/fastapi/test_parameterized_routes.py index 105ec79..6373eaa 100644 --- a/tests/fastapi/test_parameterized_routes.py +++ b/tests/fastapi/test_parameterized_routes.py @@ -26,6 +26,7 @@ from shield.fastapi.decorators import disabled, env_only, force_active, maintenance from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _make_app(env: str = "dev") -> tuple[FastAPI, ShieldEngine]: @@ -170,7 +171,7 @@ async def invoices(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/api/invoices") @@ -192,7 +193,7 @@ async def users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v2/users") @@ -218,7 +219,7 @@ async def get_item(item_id: int): return {"item_id": item_id} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) # Both the registered key and the middleware lookup must use the full # template: GET:/api/items/{item_id} diff --git a/tests/fastapi/test_router.py b/tests/fastapi/test_router.py index 0c4366f..450e41f 100644 --- a/tests/fastapi/test_router.py +++ b/tests/fastapi/test_router.py @@ -10,6 +10,7 @@ from shield.core.models import RouteStatus from shield.fastapi.decorators import disabled, env_only, maintenance from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup @pytest.fixture @@ -102,7 +103,7 @@ async def pay(): app.include_router(router) # Trigger the app's startup events directly (equivalent to server startup). - await app.router.startup() + await _trigger_startup(app) state = await engine.backend.get_state("GET:/pay") assert state.status == RouteStatus.MAINTENANCE diff --git a/tests/fastapi/test_startup_scan.py b/tests/fastapi/test_startup_scan.py index a4393cb..63c84b2 100644 --- a/tests/fastapi/test_startup_scan.py +++ b/tests/fastapi/test_startup_scan.py @@ -30,6 +30,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup # --------------------------------------------------------------------------- # Helper: simulate ASGI lifespan startup @@ -146,7 +147,7 @@ async def orders(): app.include_router(plain_router) # ShieldRouter startup hook fires first (via app.router.startup). - await app.router.startup() + await _trigger_startup(app) # Simulate a runtime state change — engine.enable overrides the decorator. await engine.enable("GET:/payments") diff --git a/tests/sdk/__init__.py b/tests/sdk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sdk/test_flag_provider.py b/tests/sdk/test_flag_provider.py new file mode 100644 index 0000000..d865691 --- /dev/null +++ b/tests/sdk/test_flag_provider.py @@ -0,0 +1,346 @@ +"""Integration tests — ShieldSDK OpenFeature flag sync. + +Covers: +* ShieldServerBackend._listen_sse() handling flag events +* ShieldSDKFlagProvider REST fetch + SSE hot-reload +* ShieldSDK.use_openfeature() integration +""" + +from __future__ import annotations + +import asyncio + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.admin.app import ShieldAdmin +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Segment, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag(key: str = "my-flag", enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name=key.title(), + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + enabled=enabled, + ) + + +def _segment(key: str = "beta") -> Segment: + return Segment( + key=key, + name="Beta Users", + included=["user1"], + ) + + +# --------------------------------------------------------------------------- +# ShieldServerBackend — flag SSE event handling +# --------------------------------------------------------------------------- + + +class TestShieldServerBackendFlagSSE: + def _make_backend(self): + from shield.core.backends.server import ShieldServerBackend + + return ShieldServerBackend(server_url="http://shield:9000", app_id="svc") + + async def test_flag_updated_event_updates_cache(self) -> None: + backend = self._make_backend() + flag = _bool_flag("cached-flag") + flag_dict = flag.model_dump(mode="json") + + # Simulate SSE event processing. + event = {"type": "flag_updated", "key": "cached-flag", "flag": flag_dict} + for q in backend._flag_subscribers: + q.put_nowait(event) + + # Directly update cache as _listen_sse would. + backend._flag_cache["cached-flag"] = flag_dict + + flags = await backend.load_all_flags() + assert len(flags) == 1 + assert flags[0]["key"] == "cached-flag" + + async def test_flag_deleted_event_removes_from_cache(self) -> None: + backend = self._make_backend() + flag = _bool_flag("rm-flag") + backend._flag_cache["rm-flag"] = flag.model_dump(mode="json") + + # Simulate deletion. + backend._flag_cache.pop("rm-flag", None) + + flags = await backend.load_all_flags() + assert flags == [] + + async def test_segment_updated_event_updates_cache(self) -> None: + backend = self._make_backend() + seg = _segment("cached-seg") + seg_dict = seg.model_dump(mode="json") + backend._segment_cache["cached-seg"] = seg_dict + + segs = await backend.load_all_segments() + assert len(segs) == 1 + + async def test_subscribe_flag_changes_yields_events(self) -> None: + backend = self._make_backend() + received: list[dict] = [] + + async def _listen() -> None: + async for event in backend.subscribe_flag_changes(): + received.append(event) + break + + task = asyncio.create_task(_listen()) + await asyncio.sleep(0.05) + + # Inject event directly into subscriber queue. + for q in backend._flag_subscribers: + q.put_nowait({"type": "flag_updated", "key": "x", "flag": {}}) + + await task + assert received[0]["type"] == "flag_updated" + + async def test_load_all_flags_returns_cached(self) -> None: + backend = self._make_backend() + flag = _bool_flag("f1") + backend._flag_cache["f1"] = flag.model_dump(mode="json") + result = await backend.load_all_flags() + assert result == [flag.model_dump(mode="json")] + + async def test_load_all_segments_returns_cached(self) -> None: + backend = self._make_backend() + seg = _segment("s1") + backend._segment_cache["s1"] = seg.model_dump(mode="json") + result = await backend.load_all_segments() + assert result == [seg.model_dump(mode="json")] + + +# --------------------------------------------------------------------------- +# ShieldSDKFlagProvider — REST fetch + SSE hot-reload +# --------------------------------------------------------------------------- + + +class TestShieldSDKFlagProvider: + @pytest.fixture + def engine(self) -> ShieldEngine: + return ShieldEngine() + + @pytest.fixture + def admin(self, engine: ShieldEngine): + return ShieldAdmin(engine=engine, enable_flags=True) + + async def test_fetch_from_server_populates_flags(self, admin, engine) -> None: + """Provider fetches flags from /api/flags on initialize().""" + flag = _bool_flag("fetch-flag") + await engine.save_flag(flag) + + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + # Build a backend with ASGI transport pointing at the admin app. + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + sdk_backend._client = AsyncClient( + transport=ASGITransport(app=admin), + base_url="http://testserver", + ) + + provider = ShieldSDKFlagProvider(sdk_backend) + await provider._fetch_from_server() + + assert "fetch-flag" in provider._flags + assert provider._flags["fetch-flag"].key == "fetch-flag" + + await sdk_backend._client.aclose() + + async def test_fetch_from_server_populates_segments(self, admin, engine) -> None: + """Provider fetches segments from /api/segments on initialize().""" + seg = _segment("fetch-seg") + await engine.save_segment(seg) + + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + sdk_backend._client = AsyncClient( + transport=ASGITransport(app=admin), + base_url="http://testserver", + ) + + provider = ShieldSDKFlagProvider(sdk_backend) + await provider._fetch_from_server() + + assert "fetch-seg" in provider._segments + + await sdk_backend._client.aclose() + + async def test_watch_sse_hot_reloads_flag(self) -> None: + """Provider _watch_sse() updates _flags when a flag_updated event arrives.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + + flag = _bool_flag("hot-flag") + watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + + # Inject a flag_updated event into the backend's subscriber queue. + for q in sdk_backend._flag_subscribers: + q.put_nowait( + {"type": "flag_updated", "key": "hot-flag", "flag": flag.model_dump(mode="json")} + ) + + await asyncio.sleep(0.1) + watch_task.cancel() + import contextlib + + with contextlib.suppress(asyncio.CancelledError): + await watch_task + + assert "hot-flag" in provider._flags + assert provider._flags["hot-flag"].enabled is True + + async def test_watch_sse_removes_deleted_flag(self) -> None: + """Provider _watch_sse() removes flag when flag_deleted event arrives.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + flag = _bool_flag("gone-flag") + provider._flags["gone-flag"] = flag + + watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + + for q in sdk_backend._flag_subscribers: + q.put_nowait({"type": "flag_deleted", "key": "gone-flag"}) + + await asyncio.sleep(0.1) + watch_task.cancel() + import contextlib + + with contextlib.suppress(asyncio.CancelledError): + await watch_task + + assert "gone-flag" not in provider._flags + + async def test_watch_sse_hot_reloads_segment(self) -> None: + """Provider _watch_sse() updates _segments when segment_updated event arrives.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + + seg = _segment("hot-seg") + watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + + for q in sdk_backend._flag_subscribers: + q.put_nowait( + { + "type": "segment_updated", + "key": "hot-seg", + "segment": seg.model_dump(mode="json"), + } + ) + + await asyncio.sleep(0.1) + watch_task.cancel() + import contextlib + + with contextlib.suppress(asyncio.CancelledError): + await watch_task + + assert "hot-seg" in provider._segments + + async def test_provider_shutdown_cancels_watch_task(self) -> None: + """shutdown() cancels the SSE watcher without raising.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + provider._watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + provider.shutdown() + assert provider._watch_task is None + + +# --------------------------------------------------------------------------- +# POST /api/flags/{key}/metrics +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# ShieldSDK.use_openfeature() integration +# --------------------------------------------------------------------------- + + +class TestShieldSDKUseOpenFeature: + async def test_use_openfeature_sets_flag_provider(self) -> None: + """use_openfeature() activates ShieldSDKFlagProvider on the engine.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield:9000", + app_id="test-svc", + ) + assert sdk.engine._flag_provider is None + sdk.use_openfeature() + assert sdk.engine._flag_provider is not None + + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + assert isinstance(sdk.engine._flag_provider, ShieldSDKFlagProvider) + + async def test_use_openfeature_enables_flag_client(self) -> None: + """use_openfeature() should also set up the flag_client property.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield:9000", + app_id="test-svc", + ) + sdk.use_openfeature() + assert sdk.engine.flag_client is not None + + async def test_use_openfeature_with_domain(self) -> None: + """use_openfeature(domain=...) uses the given domain name.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield:9000", + app_id="test-svc", + ) + # Should not raise with a custom domain. + sdk.use_openfeature(domain="payments") + assert sdk.engine._flag_provider is not None + + async def test_use_openfeature_idempotent(self) -> None: + """Calling use_openfeature() twice should not crash.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK(server_url="http://shield:9000", app_id="test-svc") + sdk.use_openfeature() + sdk.use_openfeature() # second call — should not raise + assert sdk.engine._flag_provider is not None 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/tests/test_flags_cli.py b/tests/test_flags_cli.py new file mode 100644 index 0000000..1e2fc94 --- /dev/null +++ b/tests/test_flags_cli.py @@ -0,0 +1,975 @@ +"""Tests for the shield flags / shield segments CLI commands. + +Tests are sync (def, not async) — the CLI uses anyio.run() internally +and cannot be nested inside a running pytest-asyncio event loop. + +Pattern: create an in-process ShieldAdmin, inject it via make_client mock, +invoke CLI commands through typer.testing.CliRunner. +""" + +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.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Segment, +) + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _seed_engine_with_flags(*flags: FeatureFlag, segments: list = None) -> ShieldEngine: + """Create engine, seed flags and segments synchronously.""" + e = ShieldEngine() + + async def _run() -> None: + for flag in flags: + await e.save_flag(flag) + for seg in segments or []: + await e.save_segment(seg) + + anyio.run(_run) + return e + + +def _make_flag(key: str = "my_flag", enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="My Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + enabled=enabled, + ) + + +def _make_segment(key: str = "beta", included: list[str] = None) -> Segment: + return Segment(key=key, name="Beta Users", included=included or []) + + +def _open_client(engine: ShieldEngine) -> ShieldClient: + """ShieldClient backed by in-process ShieldAdmin with flags enabled.""" + admin = ShieldAdmin(engine=engine, enable_flags=True) + return ShieldClient( + base_url="http://testserver", + transport=httpx.ASGITransport(app=admin), + ) + + +def invoke(client: ShieldClient, *args: str): + with patch("shield.cli.main.make_client", return_value=client): + return runner.invoke(app, list(args), catch_exceptions=False) + + +# --------------------------------------------------------------------------- +# shield flags list +# --------------------------------------------------------------------------- + + +class TestFlagsList: + def test_empty_shows_no_flags_message(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "No flags found" in result.output + + def test_shows_flag_row(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "my_flag" in result.output + + def test_shows_multiple_flags(self): + engine = _seed_engine_with_flags(_make_flag("flag_a"), _make_flag("flag_b")) + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "flag_a" in result.output + assert "flag_b" in result.output + + def test_filter_by_type(self): + engine = _seed_engine_with_flags(_make_flag("bool_flag")) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--type", "boolean") + assert result.exit_code == 0 + assert "bool_flag" in result.output + + def test_filter_by_type_no_match(self): + engine = _seed_engine_with_flags(_make_flag("bool_flag")) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--type", "string") + assert result.exit_code == 0 + assert "No flags found" in result.output + + def test_filter_status_enabled(self): + engine = _seed_engine_with_flags( + _make_flag("on_flag", enabled=True), _make_flag("off_flag", enabled=False) + ) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--status", "enabled") + assert result.exit_code == 0 + assert "on_flag" in result.output + assert "off_flag" not in result.output + + def test_filter_status_disabled(self): + engine = _seed_engine_with_flags( + _make_flag("on_flag", enabled=True), _make_flag("off_flag", enabled=False) + ) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--status", "disabled") + assert result.exit_code == 0 + assert "off_flag" in result.output + assert "on_flag" not in result.output + + def test_shows_count(self): + engine = _seed_engine_with_flags(_make_flag("a"), _make_flag("b"), _make_flag("c")) + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "3 flag" in result.output + + +# --------------------------------------------------------------------------- +# shield flags get +# --------------------------------------------------------------------------- + + +class TestFlagsGet: + def test_shows_flag_details(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "get", "my_flag") + assert result.exit_code == 0 + assert "my_flag" in result.output + assert "boolean" in result.output + + def test_shows_variations(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "get", "my_flag") + assert result.exit_code == 0 + assert "on" in result.output + assert "off" in result.output + + def test_shows_enabled_status(self): + engine = _seed_engine_with_flags(_make_flag(enabled=True)) + client = _open_client(engine) + result = invoke(client, "flags", "get", "my_flag") + assert result.exit_code == 0 + assert "enabled" in result.output + + def test_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "get", "nonexistent") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags create +# --------------------------------------------------------------------------- + + +class TestFlagsCreate: + def test_create_boolean_flag(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "create", "my_flag", "--name", "My Flag") + assert result.exit_code == 0 + assert "created" in result.output.lower() + assert "my_flag" in result.output + + def test_create_string_flag(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "create", "color_flag", "--name", "Color", "--type", "string" + ) + assert result.exit_code == 0 + assert "color_flag" in result.output + + def test_create_persists_flag(self): + engine = ShieldEngine() + client = _open_client(engine) + invoke(client, "flags", "create", "persist_me", "--name", "Persist") + + flags_result = invoke(client, "flags", "list") + assert "persist_me" in flags_result.output + + def test_invalid_type_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "create", "bad_flag", "--name", "Bad", "--type", "invalid_type" + ) + assert result.exit_code != 0 + + def test_create_duplicate_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "create", "my_flag", "--name", "Dupe") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags enable / disable +# --------------------------------------------------------------------------- + + +class TestFlagsEnableDisable: + def test_enable_flag(self): + engine = _seed_engine_with_flags(_make_flag(enabled=False)) + client = _open_client(engine) + result = invoke(client, "flags", "enable", "my_flag") + assert result.exit_code == 0 + assert "enabled" in result.output.lower() + + def test_disable_flag(self): + engine = _seed_engine_with_flags(_make_flag(enabled=True)) + client = _open_client(engine) + result = invoke(client, "flags", "disable", "my_flag") + assert result.exit_code == 0 + assert "disabled" in result.output.lower() + + def test_enable_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "enable", "nonexistent") + assert result.exit_code != 0 + + def test_disable_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "disable", "nonexistent") + assert result.exit_code != 0 + + def test_enable_then_verify_via_list(self): + engine = _seed_engine_with_flags(_make_flag(enabled=False)) + client = _open_client(engine) + invoke(client, "flags", "enable", "my_flag") + result = invoke(client, "flags", "list", "--status", "enabled") + assert "my_flag" in result.output + + +# --------------------------------------------------------------------------- +# shield flags delete +# --------------------------------------------------------------------------- + + +class TestFlagsDelete: + def test_delete_with_yes_flag(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "delete", "my_flag", "--yes") + assert result.exit_code == 0 + assert "deleted" in result.output.lower() + + def test_delete_removes_from_list(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + invoke(client, "flags", "delete", "my_flag", "--yes") + result = invoke(client, "flags", "list") + assert "my_flag" not in result.output + + def test_delete_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "delete", "nonexistent", "--yes") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags eval +# --------------------------------------------------------------------------- + + +class TestFlagsEval: + def test_eval_basic(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + assert "value" in result.output + assert "reason" in result.output + + def test_eval_shows_value(self): + engine = _seed_engine_with_flags(_make_flag()) # fallthrough="on" + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + assert "True" in result.output + + def test_eval_with_attributes(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "eval", + "my_flag", + "--key", + "user_1", + "--attr", + "role=admin", + "--attr", + "plan=pro", + ) + assert result.exit_code == 0 + + def test_eval_invalid_attr_format(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--attr", "not_key_value") + assert result.exit_code != 0 + + def test_eval_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "eval", "nonexistent", "--key", "user_1") + assert result.exit_code != 0 + + def test_eval_disabled_shows_off_value(self): + engine = _seed_engine_with_flags(_make_flag(enabled=False)) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + # Disabled flag → off variation (False) + assert "False" in result.output + + def test_eval_shows_reason(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + assert "FALLTHROUGH" in result.output or "OFF" in result.output or "reason" in result.output + + +# --------------------------------------------------------------------------- +# shield segments list +# --------------------------------------------------------------------------- + + +class TestSegmentsList: + def test_empty_shows_message(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "list") + assert result.exit_code == 0 + assert "No segments" in result.output + + def test_shows_segment_row(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "segments", "list") + assert result.exit_code == 0 + assert "beta" in result.output + + def test_shows_count(self): + engine = _seed_engine_with_flags(segments=[_make_segment("a"), _make_segment("b")]) + client = _open_client(engine) + result = invoke(client, "segments", "list") + assert result.exit_code == 0 + assert "2 segment" in result.output + + +# --------------------------------------------------------------------------- +# shield segments get +# --------------------------------------------------------------------------- + + +class TestSegmentsGet: + def test_shows_segment_details(self): + engine = _seed_engine_with_flags(segments=[_make_segment(included=["u1", "u2"])]) + client = _open_client(engine) + result = invoke(client, "segments", "get", "beta") + assert result.exit_code == 0 + assert "beta" in result.output + + def test_shows_included_members(self): + engine = _seed_engine_with_flags(segments=[_make_segment(included=["user_1"])]) + client = _open_client(engine) + result = invoke(client, "segments", "get", "beta") + assert result.exit_code == 0 + assert "user_1" in result.output + + def test_missing_segment_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "get", "nonexistent") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield segments create +# --------------------------------------------------------------------------- + + +class TestSegmentsCreate: + def test_create_segment(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "create", "beta", "--name", "Beta Users") + assert result.exit_code == 0 + assert "beta" in result.output + assert "created" in result.output.lower() + + def test_create_persists_segment(self): + engine = ShieldEngine() + client = _open_client(engine) + invoke(client, "segments", "create", "pro", "--name", "Pro Users") + result = invoke(client, "segments", "list") + assert "pro" in result.output + + def test_create_duplicate_exits_nonzero(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "segments", "create", "beta", "--name", "Beta") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield segments delete +# --------------------------------------------------------------------------- + + +class TestSegmentsDelete: + def test_delete_with_yes(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "segments", "delete", "beta", "--yes") + assert result.exit_code == 0 + assert "deleted" in result.output.lower() + + def test_delete_removes_from_list(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + invoke(client, "segments", "delete", "beta", "--yes") + result = invoke(client, "segments", "list") + assert "beta" not in result.output + + def test_delete_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "delete", "nonexistent", "--yes") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield segments include / exclude +# --------------------------------------------------------------------------- + + +class TestSegmentsIncludeExclude: + def test_include_adds_keys(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke( + client, + "segments", + "include", + "beta", + "--context-key", + "user_1,user_2", + ) + assert result.exit_code == 0 + assert "2" in result.output # 2 keys added + + # Verify via get + get_result = invoke(client, "segments", "get", "beta") + assert "user_1" in get_result.output + + def test_include_deduplicates(self): + engine = _seed_engine_with_flags(segments=[_make_segment(included=["user_1"])]) + client = _open_client(engine) + result = invoke( + client, + "segments", + "include", + "beta", + "--context-key", + "user_1,user_2", + ) + assert result.exit_code == 0 + # Only user_2 is new + assert "1" in result.output + + def test_exclude_adds_keys(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke( + client, + "segments", + "exclude", + "beta", + "--context-key", + "user_99", + ) + assert result.exit_code == 0 + assert "1" in result.output + + def test_include_missing_segment_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, + "segments", + "include", + "nonexistent", + "--context-key", + "user_1", + ) + assert result.exit_code != 0 + + def test_exclude_missing_segment_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, + "segments", + "exclude", + "nonexistent", + "--context-key", + "user_1", + ) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield seg alias +# --------------------------------------------------------------------------- + + +class TestSegAlias: + def test_seg_alias_works(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "seg", "list") + assert result.exit_code == 0 + assert "beta" in result.output + + +# --------------------------------------------------------------------------- +# shield flags edit (PATCH / LaunchDarkly-style mutation) +# --------------------------------------------------------------------------- + + +class TestFlagsEdit: + def test_edit_name(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag", "--name", "Renamed") + assert result.exit_code == 0 + + def test_edit_name_persists(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + invoke(client, "flags", "edit", "my_flag", "--name", "Renamed") + flag = anyio.run(engine.get_flag, "my_flag") + assert flag.name == "Renamed" + + def test_edit_description(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag", "--description", "A test flag") + assert result.exit_code == 0 + + def test_edit_off_variation(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag", "--off-variation", "on") + assert result.exit_code == 0 + flag = anyio.run(engine.get_flag, "my_flag") + assert flag.off_variation == "on" + + def test_edit_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "edit", "no_such_flag", "--name", "x") + assert result.exit_code != 0 + + def test_edit_no_options_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags variations (read-only list) +# --------------------------------------------------------------------------- + + +class TestFlagsVariations: + def test_shows_variation_names(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "variations", "my_flag") + assert result.exit_code == 0 + assert "on" in result.output + assert "off" in result.output + + def test_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "variations", "no_such_flag") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags targeting (read-only view) +# --------------------------------------------------------------------------- + + +class TestFlagsTargeting: + def test_shows_off_variation(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "targeting", "my_flag") + assert result.exit_code == 0 + assert "off" in result.output # off_variation value + + def test_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "targeting", "no_such_flag") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags add-rule / remove-rule +# --------------------------------------------------------------------------- + + +class TestFlagsAddRule: + def test_add_segment_rule(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--segment", + "beta-users", + ) + assert result.exit_code == 0 + flag = anyio.run(engine.get_flag, "my_flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].operator == "in_segment" + assert flag.rules[0].clauses[0].values == ["beta-users"] + assert flag.rules[0].variation == "on" + + def test_add_attribute_rule(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--attribute", + "plan", + "--operator", + "is", + "--values", + "pro,enterprise", + ) + assert result.exit_code == 0 + flag = anyio.run(engine.get_flag, "my_flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].attribute == "plan" + assert flag.rules[0].clauses[0].values == ["pro", "enterprise"] + + def test_add_rule_output_shows_rule_id(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--segment", + "vip", + ) + assert result.exit_code == 0 + assert "id:" in result.output + + def test_add_rule_no_segment_or_attribute_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "add-rule", "my_flag", "--variation", "on") + assert result.exit_code != 0 + + def test_add_rule_both_segment_and_attribute_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--segment", + "beta", + "--attribute", + "plan", + ) + assert result.exit_code != 0 + + def test_add_rule_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "no_such_flag", + "--variation", + "on", + "--segment", + "beta", + ) + assert result.exit_code != 0 + + +class TestFlagsRemoveRule: + def _flag_with_rule(self) -> tuple[FeatureFlag, str]: + from shield.core.feature_flags.models import Operator, RuleClause, TargetingRule + + rule = TargetingRule( + clauses=[RuleClause(attribute="key", operator=Operator.IN_SEGMENT, values=["beta"])], + variation="on", + ) + flag = _make_flag() + flag.rules = [rule] + return flag, rule.id + + def test_remove_rule_by_id(self): + flag, rule_id = self._flag_with_rule() + engine = _seed_engine_with_flags(flag) + client = _open_client(engine) + result = invoke(client, "flags", "remove-rule", "my_flag", "--rule-id", rule_id) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert updated.rules == [] + + def test_remove_rule_unknown_id_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "remove-rule", + "my_flag", + "--rule-id", + "00000000-0000-0000-0000-000000000000", + ) + assert result.exit_code != 0 + + def test_remove_rule_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "remove-rule", "no_such_flag", "--rule-id", "abc") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags add-prereq / remove-prereq +# --------------------------------------------------------------------------- + + +def _make_two_flags() -> tuple[FeatureFlag, FeatureFlag]: + return _make_flag("flag_a"), _make_flag("flag_b") + + +class TestFlagsAddPrereq: + def test_add_prereq(self): + flag_a, flag_b = _make_two_flags() + engine = _seed_engine_with_flags(flag_a, flag_b) + client = _open_client(engine) + result = invoke( + client, "flags", "add-prereq", "flag_a", "--flag", "flag_b", "--variation", "on" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "flag_a") + assert len(updated.prerequisites) == 1 + assert updated.prerequisites[0].flag_key == "flag_b" + assert updated.prerequisites[0].variation == "on" + + def test_add_prereq_updates_existing(self): + flag_a, flag_b = _make_two_flags() + engine = _seed_engine_with_flags(flag_a, flag_b) + client = _open_client(engine) + invoke(client, "flags", "add-prereq", "flag_a", "--flag", "flag_b", "--variation", "on") + result = invoke( + client, "flags", "add-prereq", "flag_a", "--flag", "flag_b", "--variation", "off" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "flag_a") + assert len(updated.prerequisites) == 1 + assert updated.prerequisites[0].variation == "off" + + def test_add_prereq_self_reference_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "add-prereq", "my_flag", "--flag", "my_flag", "--variation", "on" + ) + assert result.exit_code != 0 + + def test_add_prereq_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "add-prereq", "no_such_flag", "--flag", "other", "--variation", "on" + ) + assert result.exit_code != 0 + + +class TestFlagsRemovePrereq: + def _flag_with_prereq(self) -> FeatureFlag: + from shield.core.feature_flags.models import Prerequisite + + flag = _make_flag("flag_a") + flag.prerequisites = [Prerequisite(flag_key="flag_b", variation="on")] + return flag + + def test_remove_prereq(self): + flag = self._flag_with_prereq() + engine = _seed_engine_with_flags(flag) + client = _open_client(engine) + result = invoke(client, "flags", "remove-prereq", "flag_a", "--flag", "flag_b") + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "flag_a") + assert updated.prerequisites == [] + + def test_remove_prereq_not_found_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "remove-prereq", "my_flag", "--flag", "nonexistent_prereq") + assert result.exit_code != 0 + + def test_remove_prereq_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "remove-prereq", "no_such_flag", "--flag", "other") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags target / untarget +# --------------------------------------------------------------------------- + + +class TestFlagsTarget: + def test_target_pins_keys(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "target", "my_flag", "--variation", "on", "--keys", "user_1,user_2" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert "user_1" in updated.targets.get("on", []) + assert "user_2" in updated.targets.get("on", []) + + def test_target_appends_without_duplicates(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + invoke(client, "flags", "target", "my_flag", "--variation", "on", "--keys", "user_1") + result = invoke( + client, "flags", "target", "my_flag", "--variation", "on", "--keys", "user_1,user_2" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert updated.targets["on"].count("user_1") == 1 + assert "user_2" in updated.targets["on"] + + def test_target_unknown_variation_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "target", "my_flag", "--variation", "unknown", "--keys", "user_1" + ) + assert result.exit_code != 0 + + def test_target_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "target", "no_such_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code != 0 + + +class TestFlagsUntarget: + def _flag_with_targets(self) -> FeatureFlag: + flag = _make_flag() + flag.targets = {"on": ["user_1", "user_2"]} + return flag + + def test_untarget_removes_keys(self): + engine = _seed_engine_with_flags(self._flag_with_targets()) + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "my_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert "user_1" not in updated.targets.get("on", []) + assert "user_2" in updated.targets.get("on", []) + + def test_untarget_removes_variation_when_empty(self): + engine = _seed_engine_with_flags(self._flag_with_targets()) + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "my_flag", "--variation", "on", "--keys", "user_1,user_2" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert "on" not in updated.targets + + def test_untarget_no_targets_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "my_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code != 0 + + def test_untarget_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "no_such_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code != 0 diff --git a/uv.lock b/uv.lock index 69e51ce..19c7a28 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ trio = [ [[package]] name = "api-shield" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -68,6 +68,8 @@ all = [ { name = "httpx" }, { name = "jinja2" }, { name = "limits" }, + { name = "openfeature-sdk" }, + { name = "packaging" }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "redis" }, @@ -95,6 +97,8 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, + { name = "openfeature-sdk" }, + { name = "packaging" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -110,6 +114,10 @@ docs = [ fastapi = [ { name = "fastapi" }, ] +flags = [ + { name = "openfeature-sdk" }, + { name = "packaging" }, +] rate-limit = [ { name = "limits" }, ] @@ -138,9 +146,9 @@ requires-dist = [ { name = "aiofiles", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "anyio", specifier = ">=4.0" }, { name = "anyio", extras = ["trio"], marker = "extra == 'dev'" }, - { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.100" }, - { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.100" }, - { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100" }, + { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.115" }, + { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.115" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.115" }, { name = "httpx", marker = "extra == 'all'", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'cli'", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, @@ -157,6 +165,12 @@ requires-dist = [ { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.25" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.25" }, { name = "mypy", marker = "extra == 'dev'" }, + { name = "openfeature-sdk", marker = "extra == 'all'", specifier = ">=0.8" }, + { name = "openfeature-sdk", marker = "extra == 'dev'", specifier = ">=0.8" }, + { name = "openfeature-sdk", marker = "extra == 'flags'", specifier = ">=0.8" }, + { name = "packaging", marker = "extra == 'all'", specifier = ">=23.0" }, + { name = "packaging", marker = "extra == 'dev'", specifier = ">=23.0" }, + { name = "packaging", marker = "extra == 'flags'", specifier = ">=23.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, @@ -172,7 +186,7 @@ requires-dist = [ { name = "rich", marker = "extra == 'all'", specifier = ">=13.0" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'" }, - { name = "starlette", specifier = ">=0.27" }, + { name = "starlette", specifier = ">=0.40" }, { name = "tomli-w", marker = "extra == 'all'", specifier = ">=1.0" }, { name = "tomli-w", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "tomli-w", marker = "extra == 'toml'", specifier = ">=1.0" }, @@ -198,11 +212,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]] @@ -405,7 +419,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -414,9 +428,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999 }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407 }, ] [[package]] @@ -466,10 +480,11 @@ wheels = [ [[package]] name = "griffelib" -version = "2.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/d7/2b805e89cdc609e5b304361d80586b272ef00f6287ee63de1e571b1f71ec/griffelib-2.0.1.tar.gz", hash = "sha256:59f39eabb4c777483a3823e39e8f9e03e69df271a7e49aee64e91a8cfa91bdf5", size = 166383 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004 }, + { url = "https://files.pythonhosted.org/packages/4b/4c/cc8c68196db727cfc1432f2ad5de50aa6707e630d44b2e6361dc06d8f134/griffelib-2.0.1-py3-none-any.whl", hash = "sha256:b769eed581c0e857d362fc8fcd8e57ecd2330c124b6104ac8b4c1c86d76970aa", size = 142377 }, ] [[package]] @@ -817,7 +832,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 +847,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]] @@ -939,6 +954,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, ] +[[package]] +name = "openfeature-sdk" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/08/f6698d0614b8703170117b786bd77b7b0a04f3ee00f19fbe9b360d2dee69/openfeature_sdk-0.8.4.tar.gz", hash = "sha256:66abf71f928ec8c0db1111072bb0ef2635dfbd09510f77f4b548e5d0ea0e6c1a", size = 29676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/80/f6532778188c573cc83790b11abccde717d4c1442514e722d6bb6140e55c/openfeature_sdk-0.8.4-py3-none-any.whl", hash = "sha256:805ba090669798fc343ca9fdcbc56ff0f4b57bf6757533f0854d2021192e620a", size = 35986 }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -1279,14 +1303,14 @@ wheels = [ [[package]] name = "redis" -version = "7.3.0" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/82/4d1a5279f6c1251d3d2a603a798a1137c657de9b12cfc1fba4858232c4d2/redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034", size = 4928081 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/28/84e57fce7819e81ec5aa1bd31c42b89607241f4fb1a3ea5b0d2dbeaea26c/redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364", size = 404379 }, + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772 }, ] [[package]] @@ -1319,27 +1343,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]] @@ -1389,15 +1413,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702 } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289 } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272 }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651 }, ] [[package]]