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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ These features are framework-agnostic and available to any adapter.
| ⏰ **Scheduled windows** | `asyncio`-native scheduler — maintenance windows activate and deactivate automatically |
| 🔔 **Webhooks** | Fire HTTP POST on every state change — built-in Slack formatter and custom formatters supported |
| 🚦 **Rate limiting** | Per-IP, per-user, per-API-key, or global counters — tiered limits, burst allowance, runtime mutation |
| 🏗️ **Shield Server** | Centralised control plane for multi-service architectures — SDK clients sync state via SSE with zero per-request latency |
| 🌐 **Multi-service CLI** | `SHIELD_SERVICE` env var scopes every command; `shield services` lists connected services |

### Framework adapters

Expand Down Expand Up @@ -222,14 +224,36 @@ api-shield is built on the **ASGI** standard. The core (`shield.core`) is comple

## Backends

| Backend | Persistence | Multi-instance |
|---|---|---|
| `MemoryBackend` | No | No |
| `FileBackend` | Yes | No |
| `RedisBackend` | Yes | Yes |
### Embedded mode (single service)

| Backend | Persistence | Multi-instance | Best for |
|---|---|---|---|
| `MemoryBackend` | No | No | Development, tests |
| `FileBackend` | Yes | No (single process) | Simple single-instance prod |
| `RedisBackend` | Yes | Yes | Load-balanced / multi-worker prod |

For rate limiting in multi-worker deployments, use `RedisBackend` — counters are atomic and shared across all processes.

### Shield Server mode (multi-service)

Run a dedicated `ShieldServer` process and connect each service via `ShieldSDK`. State is managed centrally; enforcement happens locally with zero per-request network overhead.

```python
# Shield Server (centralised — runs once)
from shield.server import ShieldServer
shield_app = ShieldServer(backend=MemoryBackend(), auth=("admin", "secret"))

# Each service (connects to the Shield Server)
from shield.sdk import ShieldSDK
sdk = ShieldSDK(server_url="http://shield-server:9000", app_id="payments-service")
sdk.attach(app)
```

| Scenario | Shield Server backend | SDK `rate_limit_backend` |
|---|---|---|
| Multi-service, single replica each | `MemoryBackend` or `FileBackend` | not needed |
| Multi-service, multiple replicas | `RedisBackend` | `RedisBackend` (shared counters) |

---

## Documentation
Expand All @@ -242,9 +266,11 @@ Full documentation at **[attakay78.github.io/api-shield](https://attakay78.githu
| [Decorators reference](https://attakay78.github.io/api-shield/reference/decorators/) | All decorator options |
| [Rate limiting](https://attakay78.github.io/api-shield/tutorial/rate-limiting/) | Per-IP, per-user, tiered limits |
| [ShieldEngine reference](https://attakay78.github.io/api-shield/reference/engine/) | Programmatic control |
| [Backends](https://attakay78.github.io/api-shield/tutorial/backends/) | Memory, File, Redis, custom |
| [Backends](https://attakay78.github.io/api-shield/tutorial/backends/) | Memory, File, Redis, Shield Server, custom |
| [Admin dashboard](https://attakay78.github.io/api-shield/tutorial/admin-dashboard/) | Mounting ShieldAdmin |
| [CLI reference](https://attakay78.github.io/api-shield/reference/cli/) | All CLI commands |
| [Shield Server guide](https://attakay78.github.io/api-shield/guides/shield-server/) | Multi-service centralized control |
| [Distributed deployments](https://attakay78.github.io/api-shield/guides/distributed/) | Multi-instance backend guide |
| [Production guide](https://attakay78.github.io/api-shield/guides/production/) | Monitoring & deployment automation |

## License
Expand Down
182 changes: 182 additions & 0 deletions docs/adapters/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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="<token-from-shield-login>",
)

# 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"
Expand Down
22 changes: 22 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

---

## [Unreleased]

### Fixed

- **Unprotected routes table not updating after adding a policy**: routes stayed visible in the "Unprotected Routes" section after a rate limit was set because the filter compared `METHOD:/path` display values against path-only strings stripped of the method prefix; comparison now uses the full composite key correctly.
- **Invalid rate limit strings accepted silently**: submitting a malformed limit (e.g. `2/minutesedrr`) was stored and caused a 500 on every request; the engine now validates the limit string before persisting and the dashboard modal surfaces an inline error with the correct format hint.

### Added

- **`SHIELD_SERVICE` env var fallback on all `--service` CLI options**: `shield status`, `shield enable`, `shield disable`, `shield maintenance`, and `shield schedule` all read `SHIELD_SERVICE` automatically — set it once with `export SHIELD_SERVICE=payments-service` and every command scopes itself to that service without repeating `--service`. An explicit `--service` flag always wins.
- **`shield current-service` command**: shows the active service context from the `SHIELD_SERVICE` environment variable, or a hint to set it when the variable is absent.
- **`shield services` command**: lists all distinct service names registered with the Shield Server, so you can discover which services are connected before switching context.
- **Dashboard "Unprotected Routes" section**: the Rate Limits page now surfaces all routes that have no rate limit policy, with an "Add Limit" button per row that opens a modal to configure method, limit, algorithm, and key strategy in-place — no CLI required.
- **Route existence validation in `set_rate_limit_policy()`**: attempting to add a rate limit policy for a route that does not exist now raises `RouteNotFoundException` immediately; the REST API returns `404` and the CLI prints a clear error, preventing phantom policies from accumulating.
- **`ShieldSDK.rate_limit_backend` parameter**: pass a `RedisBackend` instance to share rate limit counters across all replicas of a service connected to the same Shield Server; without it each replica enforces limits independently.
- **Rate limit policy SSE propagation to SDK clients**: policies set or deleted via the CLI or dashboard are now broadcast over the Shield Server's SSE stream as typed `rl_policy` envelopes and applied to every connected SDK client in real time — no restart required.
- **`ShieldSDK` auto-login (`username` / `password` params)**: pass credentials directly to `ShieldSDK` instead of a pre-issued token; on startup the SDK calls `POST /api/auth/login` with `platform="sdk"` and caches the resulting long-lived token for the life of the process — no manual token management required.
- **Separate SDK token lifetime (`sdk_token_expiry`)**: `ShieldServer` and `ShieldAdmin` now accept `sdk_token_expiry` (default 1 year) independently from `token_expiry` (default 24 h for dashboard / CLI users), so service apps can run indefinitely without re-authentication while human sessions remain short-lived.
- **`platform` field on `POST /api/auth/login`**: the login endpoint now accepts `"cli"` (default) or `"sdk"` in the request body; `"sdk"` tokens use `sdk_token_expiry` and are intended for machine-to-machine service authentication.

---

## [0.7.0]

### Added
Expand Down
35 changes: 33 additions & 2 deletions docs/guides/distributed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

```
Expand Down
Loading
Loading