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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
106 changes: 99 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div align="center">
<img src="api-shield-logo.svg" alt="API Shield" width="600"/>

<p><strong>Route(API) lifecycle management for ASGI Python web frameworks — maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.</strong></p>
<p><strong>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.</strong></p>

<a href="https://pypi.org/project/api-shield"><img src="https://img.shields.io/pypi/v/api-shield?color=F59E0B&label=pypi&cacheSeconds=300" alt="PyPI"></a>
<a href="https://pypi.org/project/api-shield"><img src="https://img.shields.io/pypi/pyversions/api-shield?color=F59E0B" alt="Python versions"></a>
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
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
24 changes: 24 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading