lockwatch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockwatch-0.1.0/.coverage +0 -0
- lockwatch-0.1.0/.github/workflows/publish.yml +62 -0
- lockwatch-0.1.0/PKG-INFO +309 -0
- lockwatch-0.1.0/PLAN.md +173 -0
- lockwatch-0.1.0/README.md +269 -0
- lockwatch-0.1.0/alembic/README +1 -0
- lockwatch-0.1.0/alembic/env.py +97 -0
- lockwatch-0.1.0/alembic/script.py.mako +28 -0
- lockwatch-0.1.0/alembic/versions/af108e21e735_initial_audit_log_schema.py +69 -0
- lockwatch-0.1.0/alembic.ini +151 -0
- lockwatch-0.1.0/high_level.txt +31 -0
- lockwatch-0.1.0/improvements.txt +88 -0
- lockwatch-0.1.0/inconsistencies.txt +75 -0
- lockwatch-0.1.0/low_level.txt +137 -0
- lockwatch-0.1.0/next_steps.txt +89 -0
- lockwatch-0.1.0/pyproject.toml +88 -0
- lockwatch-0.1.0/src/lockwatch/__init__.py +47 -0
- lockwatch-0.1.0/src/lockwatch/anomaly.py +161 -0
- lockwatch-0.1.0/src/lockwatch/audit.py +179 -0
- lockwatch-0.1.0/src/lockwatch/jwt_rotation.py +272 -0
- lockwatch-0.1.0/src/lockwatch/middleware.py +429 -0
- lockwatch-0.1.0/src/lockwatch/rate_limiter.py +203 -0
- lockwatch-0.1.0/tests/conftest.py +27 -0
- lockwatch-0.1.0/tests/test_anomaly.py +98 -0
- lockwatch-0.1.0/tests/test_audit.py +136 -0
- lockwatch-0.1.0/tests/test_flask.py +443 -0
- lockwatch-0.1.0/tests/test_jwt_rotation.py +141 -0
- lockwatch-0.1.0/tests/test_middleware.py +217 -0
- lockwatch-0.1.0/tests/test_rate_limiter.py +206 -0
|
Binary file
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*" # trigger on v0.1.0, v1.2.3, etc.
|
|
7
|
+
workflow_dispatch: # allow manual trigger for testing
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Run tests before publish
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Set up Python 3.11
|
|
17
|
+
uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.11"
|
|
20
|
+
|
|
21
|
+
- name: Install Hatch
|
|
22
|
+
run: pip install hatch
|
|
23
|
+
|
|
24
|
+
- name: Run tests
|
|
25
|
+
run: hatch run test
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
name: Build and publish to PyPI
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
needs: test
|
|
31
|
+
# Use PyPI's trusted publisher (OIDC) — no API token needed in secrets
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write # required for OIDC token exchange with PyPI
|
|
34
|
+
contents: read
|
|
35
|
+
|
|
36
|
+
environment:
|
|
37
|
+
name: pypi
|
|
38
|
+
url: https://pypi.org/project/lockwatch/
|
|
39
|
+
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v4
|
|
42
|
+
|
|
43
|
+
- name: Set up Python 3.11
|
|
44
|
+
uses: actions/setup-python@v5
|
|
45
|
+
with:
|
|
46
|
+
python-version: "3.11"
|
|
47
|
+
|
|
48
|
+
- name: Install build tools
|
|
49
|
+
run: pip install hatch build
|
|
50
|
+
|
|
51
|
+
- name: Build wheel and sdist
|
|
52
|
+
run: python -m build
|
|
53
|
+
|
|
54
|
+
- name: Verify distribution files
|
|
55
|
+
run: |
|
|
56
|
+
pip install twine
|
|
57
|
+
twine check dist/*
|
|
58
|
+
|
|
59
|
+
- name: Publish to PyPI
|
|
60
|
+
# Uses OIDC trusted publisher — configure at https://pypi.org/manage/account/publishing/
|
|
61
|
+
# Project name: lockwatch, workflow: publish.yml, environment: pypi
|
|
62
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
lockwatch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lockwatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: API security middleware for FastAPI and Flask: rate limiting, JWT rotation, anomaly detection, audit logging
|
|
5
|
+
Project-URL: Homepage, https://github.com/jordanho/lockwatch
|
|
6
|
+
Project-URL: Repository, https://github.com/jordanho/lockwatch
|
|
7
|
+
Project-URL: Issues, https://github.com/jordanho/lockwatch/issues
|
|
8
|
+
Author-email: Jordan Ho <jordanjho@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: fastapi,flask,jwt,middleware,rate-limiting,redis,security
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
20
|
+
Requires-Dist: redis[asyncio]>=5.0.0
|
|
21
|
+
Provides-Extra: all
|
|
22
|
+
Requires-Dist: alembic>=1.13.0; extra == 'all'
|
|
23
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'all'
|
|
24
|
+
Requires-Dist: fastapi>=0.111.0; extra == 'all'
|
|
25
|
+
Requires-Dist: flask>=3.0.0; extra == 'all'
|
|
26
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0; extra == 'all'
|
|
27
|
+
Requires-Dist: starlette>=0.37.0; extra == 'all'
|
|
28
|
+
Requires-Dist: werkzeug>=3.0.0; extra == 'all'
|
|
29
|
+
Provides-Extra: audit
|
|
30
|
+
Requires-Dist: alembic>=1.13.0; extra == 'audit'
|
|
31
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'audit'
|
|
32
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0; extra == 'audit'
|
|
33
|
+
Provides-Extra: fastapi
|
|
34
|
+
Requires-Dist: fastapi>=0.111.0; extra == 'fastapi'
|
|
35
|
+
Requires-Dist: starlette>=0.37.0; extra == 'fastapi'
|
|
36
|
+
Provides-Extra: flask
|
|
37
|
+
Requires-Dist: flask>=3.0.0; extra == 'flask'
|
|
38
|
+
Requires-Dist: werkzeug>=3.0.0; extra == 'flask'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
[](https://badge.fury.io/py/lockwatch)
|
|
42
|
+
[](https://www.python.org/downloads/)
|
|
43
|
+
[](https://opensource.org/licenses/MIT)
|
|
44
|
+
|
|
45
|
+
# LockWatch
|
|
46
|
+
|
|
47
|
+
Drop-in API security middleware for FastAPI and Flask: sliding-window rate limiting (Redis sorted set), JWT rotation with refresh-token blacklist, IP burst anomaly detection with webhook alerts, and Postgres audit logging — all wired into a single `add_middleware` call.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Architecture
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
┌─────────────────────────────────────────────┐
|
|
55
|
+
Incoming │ LockWatchMiddleware │
|
|
56
|
+
Request ────────►│ │
|
|
57
|
+
│ ┌──────────────┐ ┌───────────────────┐ │
|
|
58
|
+
│ │ RateLimiter │ │ AnomalyDetector │ │
|
|
59
|
+
│ │ │ │ │ │
|
|
60
|
+
│ │ Redis sorted │ │ burst window + │ │
|
|
61
|
+
│ │ set sliding │ │ webhook alert │ │
|
|
62
|
+
│ │ window │ │ (fire-and-forget) │ │
|
|
63
|
+
│ └──────┬───────┘ └────────┬──────────┘ │
|
|
64
|
+
│ │ 429 if over limit │ │
|
|
65
|
+
│ ▼ ▼ │
|
|
66
|
+
│ ┌─────────────────────────────────────┐ │
|
|
67
|
+
│ │ Application Handler │ │
|
|
68
|
+
│ └─────────────────┬───────────────────┘ │
|
|
69
|
+
│ │ │
|
|
70
|
+
│ ▼ │
|
|
71
|
+
│ ┌─────────────────────────────────────┐ │
|
|
72
|
+
│ │ AuditLogger (background task) │ │
|
|
73
|
+
│ │ → Postgres lockwatch_audit_log │ │
|
|
74
|
+
│ └─────────────────────────────────────┘ │
|
|
75
|
+
└─────────────────────────────────────────────┘
|
|
76
|
+
│
|
|
77
|
+
Response ◄┘
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every request flows through the rate limiter first (hard block at 429), then the anomaly detector (non-blocking webhook alert), then your application, then an async audit log write that is never on the response critical path.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Why I Built This
|
|
85
|
+
|
|
86
|
+
Bolting rate limiters, audit logs, and JWT rotation into every FastAPI project is tedious boilerplate — the same 50 lines of Redis plumbing, every time. LockWatch makes it a single `add_middleware` call so the security layer doesn't crowd the application logic.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# FastAPI / Starlette
|
|
94
|
+
pip install lockwatch[fastapi]
|
|
95
|
+
|
|
96
|
+
# Flask / WSGI
|
|
97
|
+
pip install lockwatch[flask]
|
|
98
|
+
|
|
99
|
+
# Postgres audit log
|
|
100
|
+
pip install lockwatch[audit]
|
|
101
|
+
|
|
102
|
+
# Everything
|
|
103
|
+
pip install lockwatch[all]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Quickstart — FastAPI
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from fastapi import FastAPI
|
|
112
|
+
from lockwatch import LockWatchMiddleware
|
|
113
|
+
|
|
114
|
+
app = FastAPI()
|
|
115
|
+
|
|
116
|
+
app.add_middleware(
|
|
117
|
+
LockWatchMiddleware,
|
|
118
|
+
redis_url="redis://localhost:6379",
|
|
119
|
+
rate_limit_requests=100, # allow 100 requests …
|
|
120
|
+
rate_limit_window_seconds=60, # … per 60-second sliding window
|
|
121
|
+
burst_threshold=50, # anomaly alert if >50 req in 10s
|
|
122
|
+
burst_window_seconds=10,
|
|
123
|
+
webhook_urls=["https://hooks.example.com/lockwatch-alert"],
|
|
124
|
+
audit_database_url="postgresql+asyncpg://user:pass@host/db", # optional
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@app.get("/items")
|
|
128
|
+
async def list_items():
|
|
129
|
+
return {"items": []}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
When a client exceeds the limit, LockWatch automatically returns:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
HTTP 429 Too Many Requests
|
|
136
|
+
Retry-After: 42
|
|
137
|
+
X-RateLimit-Limit: 100
|
|
138
|
+
X-RateLimit-Remaining: 0
|
|
139
|
+
X-RateLimit-Reset: 1717516800
|
|
140
|
+
|
|
141
|
+
{"error": "rate_limit_exceeded", "retry_after": 42}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Quickstart — Flask
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from flask import Flask
|
|
150
|
+
from lockwatch.middleware import LockWatchFlaskMiddleware
|
|
151
|
+
|
|
152
|
+
app = Flask(__name__)
|
|
153
|
+
app.wsgi_app = LockWatchFlaskMiddleware(
|
|
154
|
+
app.wsgi_app,
|
|
155
|
+
redis_url="redis://localhost:6379",
|
|
156
|
+
rate_limit_requests=200,
|
|
157
|
+
rate_limit_window_seconds=60,
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Flask — Anomaly Detection + JWT Verification
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from flask import Flask, g
|
|
167
|
+
from lockwatch import JWTRotationManager, JWTConfig
|
|
168
|
+
from lockwatch.middleware import flask_jwt_required, LockWatchFlaskMiddleware
|
|
169
|
+
|
|
170
|
+
app = Flask(__name__)
|
|
171
|
+
|
|
172
|
+
# Rate limiting + anomaly detection wired at the WSGI layer
|
|
173
|
+
app.wsgi_app = LockWatchFlaskMiddleware(
|
|
174
|
+
app.wsgi_app,
|
|
175
|
+
redis_url="redis://localhost:6379",
|
|
176
|
+
rate_limit_requests=200,
|
|
177
|
+
rate_limit_window_seconds=60,
|
|
178
|
+
burst_threshold=50, # enables anomaly detection; 0 = disabled
|
|
179
|
+
burst_window_seconds=10,
|
|
180
|
+
webhook_urls=["https://hooks.example.com/alert"],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# JWT verification per route via decorator
|
|
184
|
+
jwt_mgr = JWTRotationManager(redis_client, JWTConfig(rsa_private_key_pem=pem))
|
|
185
|
+
|
|
186
|
+
@app.route("/protected")
|
|
187
|
+
@flask_jwt_required(jwt_mgr)
|
|
188
|
+
def protected():
|
|
189
|
+
return {"user": g.jwt_claims["sub"]}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## JWT Rotation
|
|
195
|
+
|
|
196
|
+
Manage short-lived access tokens and refresh-token blacklisting without a database round-trip on every request:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
200
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
201
|
+
Encoding, PrivateFormat, NoEncryption,
|
|
202
|
+
)
|
|
203
|
+
from lockwatch import JWTRotationManager, JWTConfig
|
|
204
|
+
|
|
205
|
+
# Generate once; persist the PEM in your secrets store
|
|
206
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
207
|
+
pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()).decode()
|
|
208
|
+
|
|
209
|
+
manager = JWTRotationManager(
|
|
210
|
+
redis_client=redis,
|
|
211
|
+
config=JWTConfig(rsa_private_key_pem=pem),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
pair = await manager.issue_token_pair(subject="user:42", claims={"role": "admin"})
|
|
215
|
+
# pair.access_token — RS256 JWT, 15-minute TTL
|
|
216
|
+
# pair.refresh_token — RS256 JWT, 7-day TTL, hash stored in Redis
|
|
217
|
+
|
|
218
|
+
new_pair = await manager.rotate(pair.refresh_token) # atomic swap via WATCH/MULTI/EXEC
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Anomaly Detection
|
|
224
|
+
|
|
225
|
+
Standalone burst detector — usable without the full middleware:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from lockwatch import AnomalyDetector, AnomalyConfig
|
|
229
|
+
|
|
230
|
+
detector = AnomalyDetector(
|
|
231
|
+
redis_client=redis,
|
|
232
|
+
config=AnomalyConfig(
|
|
233
|
+
burst_threshold=50,
|
|
234
|
+
burst_window_seconds=10,
|
|
235
|
+
webhook_urls=["https://hooks.example.com/alert"],
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
is_burst = await detector.check(ip="1.2.3.4", endpoint="/api/login", method="POST")
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Audit Log
|
|
245
|
+
|
|
246
|
+
Query who did what and when:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from lockwatch import AuditLogger, AuditQuery
|
|
250
|
+
|
|
251
|
+
logger = AuditLogger(database_url="postgresql+asyncpg://user:pass@host/db")
|
|
252
|
+
await logger.init() # creates table if not exists (idempotent)
|
|
253
|
+
|
|
254
|
+
entries = await logger.query(AuditQuery(
|
|
255
|
+
user_id="user:42",
|
|
256
|
+
anomalies_only=True,
|
|
257
|
+
limit=50,
|
|
258
|
+
))
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Run Alembic migrations against your Postgres instance:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
export LOCKWATCH_DATABASE_URL="postgresql+asyncpg://user:pass@host/db"
|
|
265
|
+
alembic upgrade head
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Configuration Reference
|
|
271
|
+
|
|
272
|
+
| Parameter | Env var override | Default | Description |
|
|
273
|
+
|---|---|---|---|
|
|
274
|
+
| `redis_url` | `REDIS_URL` | — | Redis connection URL (required) |
|
|
275
|
+
| `rate_limit_requests` | — | `100` | Max requests per window |
|
|
276
|
+
| `rate_limit_window_seconds` | — | `60` | Sliding window size in seconds |
|
|
277
|
+
| `burst_threshold` | — | `50` | Request count that triggers anomaly alert |
|
|
278
|
+
| `burst_window_seconds` | — | `10` | Burst detection window in seconds |
|
|
279
|
+
| `webhook_urls` | — | `[]` | List of HTTPS endpoints for anomaly alerts |
|
|
280
|
+
| `audit_database_url` | `LOCKWATCH_DATABASE_URL` | `None` | Postgres URL for audit log |
|
|
281
|
+
| `on_redis_failure` | — | `"allow"` | `"allow"` (fail open) or `"deny"` (fail closed) |
|
|
282
|
+
| `jwt_secret` | `JWT_SECRET` | — | HMAC secret for JWT signing |
|
|
283
|
+
| `access_token_ttl` | — | `900` | Access token lifetime in seconds |
|
|
284
|
+
| `anomaly_window` | — | `10` | Alias for `burst_window_seconds` |
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## What it Does
|
|
289
|
+
|
|
290
|
+
- **Sliding-window rate limiter** — Redis sorted set records each request timestamp; window is computed by pruning entries older than `rate_limit_window_seconds`. O(log N) per operation, true sliding semantics (no thundering-herd at window boundary), atomic pipeline.
|
|
291
|
+
- **JWT rotation with refresh blacklist** — issues short-lived access tokens backed by opaque refresh tokens stored in Redis. `rotate()` atomically swaps old for new and blacklists the old refresh token with a TTL — no stale token reuse.
|
|
292
|
+
- **IP burst anomaly detection** — separate Redis sorted set per IP in a short burst window. When the threshold is crossed, fires async webhook alerts with a 60-second dedup window (one alert per IP per minute). Non-blocking — never delays the response.
|
|
293
|
+
- **Postgres audit log** — every request gets one row in `lockwatch_audit_log` including timestamp, IP, user_id, endpoint, method, status code, rate_limit_hit flag, anomaly_detected flag, and response latency. Writes are background tasks (`asyncio.create_task`) — a failed write logs a warning but never affects the response.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Running Tests
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
pip install lockwatch[all]
|
|
301
|
+
pip install pytest pytest-asyncio fakeredis[aioredis] aiosqlite
|
|
302
|
+
pytest --cov=lockwatch --cov-report=term-missing
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## License
|
|
308
|
+
|
|
309
|
+
MIT © Jordan Ho
|
lockwatch-0.1.0/PLAN.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# LockWatch — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> Status: 📋 Plan written | Last updated: 2026-06-03
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
LockWatch is an open-source API security middleware library for Python (FastAPI and Flask), published to PyPI. It provides production-grade security primitives — sliding-window rate limiting, JWT rotation with refresh token blacklisting, IP burst anomaly detection with webhook alerts, and a Postgres audit log — as drop-in middleware. The goal is a single `pip install lockwatch` that gives any FastAPI or Flask app the security layer that teams typically wire together from multiple half-baked packages.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────────────────────────────────────────────────┐
|
|
13
|
+
│ FastAPI / Flask App │
|
|
14
|
+
│ │
|
|
15
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
16
|
+
│ │ LockWatch Middleware │ │
|
|
17
|
+
│ │ │ │
|
|
18
|
+
│ │ ┌──────────────┐ ┌───────────────────────┐ │ │
|
|
19
|
+
│ │ │ RateLimiter │ │ AnomalyDetector │ │ │
|
|
20
|
+
│ │ │ (per-key │ │ (burst detection + │ │ │
|
|
21
|
+
│ │ │ sliding │ │ webhook alerts) │ │ │
|
|
22
|
+
│ │ │ window) │ └───────────┬───────────┘ │ │
|
|
23
|
+
│ │ └──────┬───────┘ │ │ │
|
|
24
|
+
│ │ │ │ │ │
|
|
25
|
+
│ │ ┌──────▼───────────────────────▼───────────┐ │ │
|
|
26
|
+
│ │ │ Redis │ │ │
|
|
27
|
+
│ │ │ rate_limit:{key} → sorted set │ │ │
|
|
28
|
+
│ │ │ refresh_token:{jti} → hash │ │ │
|
|
29
|
+
│ │ │ blacklist:{jti} → 1 │ │ │
|
|
30
|
+
│ │ └──────────────────────────────────────────┘ │ │
|
|
31
|
+
│ │ │ │
|
|
32
|
+
│ │ ┌─────────────────────────────────────────┐ │ │
|
|
33
|
+
│ │ │ JWTRotationManager │ │ │
|
|
34
|
+
│ │ │ access (15min RS256) + refresh (7d) │ │ │
|
|
35
|
+
│ │ └─────────────────────────────────────────┘ │ │
|
|
36
|
+
│ │ │ │
|
|
37
|
+
│ │ ┌─────────────────────────────────────────┐ │ │
|
|
38
|
+
│ │ │ AuditLogger │ │ │
|
|
39
|
+
│ │ │ Postgres: requests, user, endpoint, │ │ │
|
|
40
|
+
│ │ │ status_code, rate_limit_hit │ │ │
|
|
41
|
+
│ │ └─────────────────────────────────────────┘ │ │
|
|
42
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
43
|
+
└─────────────────────────────────────────────────────────┘
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Tech Stack
|
|
47
|
+
|
|
48
|
+
- **Python 3.11** — match modern FastAPI/production deployments; 3.11 perf improvements matter for middleware hot path
|
|
49
|
+
- **FastAPI** — target framework; async-first design maps cleanly to starlette middleware base
|
|
50
|
+
- **Flask** — second target; WSGI middleware via `werkzeug.wsgi.DispatcherMiddleware`; covers the large Flask user base
|
|
51
|
+
- **redis-py (async)** — `aioredis`-compatible async client; sorted set primitives are exactly what sliding-window needs (ZADD/ZREMRANGEBYSCORE/ZCARD in a pipeline = atomic window check)
|
|
52
|
+
- **SQLAlchemy 2 + asyncpg** — async ORM for audit log writes; asyncpg is the fastest Postgres driver available; SQLAlchemy gives portability
|
|
53
|
+
- **python-jose[cryptography]** — RS256 JWT signing/verification; JWK support needed for JWKS endpoint later; well-audited
|
|
54
|
+
- **hatchling** — PEP 517 build backend; simpler than setuptools; native pyproject.toml support; used by major OSS projects (FastAPI itself)
|
|
55
|
+
- **pytest + pytest-asyncio + fakeredis** — unit tests without needing a live Redis; fakeredis implements the full sorted set API
|
|
56
|
+
|
|
57
|
+
## Implementation Checklist
|
|
58
|
+
|
|
59
|
+
### Phase 1 — Project scaffold
|
|
60
|
+
- [x] Create directory structure (done by scaffolding agent)
|
|
61
|
+
- [ ] Write `pyproject.toml` (hatchling, optional deps: `[fastapi]`, `[flask]`, `[audit]`)
|
|
62
|
+
- [ ] Write `src/lockwatch/__init__.py` with public API exports
|
|
63
|
+
- [ ] Set up `tests/conftest.py` with fakeredis + SQLite fixtures
|
|
64
|
+
|
|
65
|
+
### Phase 2 — Sliding-window rate limiter
|
|
66
|
+
- [ ] Implement `RateLimiter` class in `rate_limiter.py`
|
|
67
|
+
- [ ] `async def is_allowed(key: str) -> tuple[bool, RateLimitState]`
|
|
68
|
+
- [ ] Redis pipeline: `ZADD`, `ZREMRANGEBYSCORE` (prune old), `ZCARD`, `EXPIRE`
|
|
69
|
+
- [ ] Config dataclass: `requests_per_window`, `window_seconds`, `key_func` (callable)
|
|
70
|
+
- [ ] Built-in key functions: `key_by_ip`, `key_by_user_id`, `key_by_api_key`
|
|
71
|
+
- [ ] Return headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
|
72
|
+
- [ ] Write `tests/test_rate_limiter.py` (5 test cases, fakeredis)
|
|
73
|
+
|
|
74
|
+
### Phase 3 — JWT rotation
|
|
75
|
+
- [ ] Implement `JWTRotationManager` in `jwt_rotation.py`
|
|
76
|
+
- [ ] `issue_token_pair(subject, claims) -> TokenPair` (access 15min + refresh 7d)
|
|
77
|
+
- [ ] Store `SHA256(refresh_token)` in Redis with 7d TTL
|
|
78
|
+
- [ ] `rotate(refresh_token) -> TokenPair` — validate hash, blacklist old jti, issue new pair
|
|
79
|
+
- [ ] `blacklist(jti)` — add to Redis blacklist set (TTL = remaining token lifetime)
|
|
80
|
+
- [ ] `verify_access(token) -> Claims` — check signature + blacklist
|
|
81
|
+
- [ ] Config: RSA private key path or inline PEM, issuer, audience
|
|
82
|
+
|
|
83
|
+
### Phase 4 — Anomaly detection
|
|
84
|
+
- [ ] Implement `AnomalyDetector` in `anomaly.py`
|
|
85
|
+
- [ ] Per-IP sliding window (separate Redis sorted set from rate limiter)
|
|
86
|
+
- [ ] Config: `burst_threshold` (requests), `burst_window_seconds`, `webhook_urls: list[str]`
|
|
87
|
+
- [ ] `async def check(ip: str, request_info: dict) -> bool` — returns True if anomaly
|
|
88
|
+
- [ ] Async webhook dispatch (httpx, non-blocking, fire-and-forget with timeout)
|
|
89
|
+
- [ ] Alert payload: `{ ip, timestamp, request_count, window_seconds, endpoint }`
|
|
90
|
+
- [ ] Deduplicate alerts: one alert per IP per 60s (Redis key with TTL)
|
|
91
|
+
|
|
92
|
+
### Phase 5 — Postgres audit log
|
|
93
|
+
- [ ] Implement `AuditLogger` in `audit.py`
|
|
94
|
+
- [ ] SQLAlchemy model `AuditLogEntry`: `id`, `timestamp`, `user_id`, `ip`, `endpoint`, `method`, `status_code`, `rate_limit_hit`, `anomaly_detected`, `latency_ms`
|
|
95
|
+
- [ ] `async def log(entry: AuditLogEntry) -> None` (background task — don't block response)
|
|
96
|
+
- [ ] `async def query(filters: AuditQuery) -> list[AuditLogEntry]`
|
|
97
|
+
- [ ] Alembic migration in `src/lockwatch/migrations/`
|
|
98
|
+
- [ ] Index on `(timestamp, user_id)` and `(timestamp, ip)`
|
|
99
|
+
|
|
100
|
+
### Phase 6 — Middleware classes
|
|
101
|
+
- [ ] `LockWatchMiddleware` (FastAPI/Starlette) in `middleware.py`
|
|
102
|
+
- [ ] Extends `starlette.middleware.base.BaseHTTPMiddleware`
|
|
103
|
+
- [ ] Call order: rate check → anomaly check → dispatch → audit log
|
|
104
|
+
- [ ] Inject `request.state.lockwatch` with rate limit state for downstream use
|
|
105
|
+
- [ ] 429 response with `Retry-After` header on rate limit
|
|
106
|
+
- [ ] Config object passed at init time
|
|
107
|
+
- [ ] `LockWatchFlaskMiddleware` (WSGI) in `middleware.py`
|
|
108
|
+
- [ ] Wrap WSGI app, extract IP + headers pre-dispatch
|
|
109
|
+
- [ ] Note: Flask middleware is sync-only; use `asyncio.run()` bridge or sync Redis client fallback
|
|
110
|
+
|
|
111
|
+
### Phase 7 — PyPI publish
|
|
112
|
+
- [ ] Add `__version__` to `__init__.py` (semver, start at `0.1.0`)
|
|
113
|
+
- [ ] Write `.github/workflows/publish.yml` — trigger on `v*` tag push, build + twine upload
|
|
114
|
+
- [ ] Set up PyPI trusted publisher (OIDC, no token in secrets)
|
|
115
|
+
- [ ] Write `CHANGELOG.md` template
|
|
116
|
+
|
|
117
|
+
### Phase 8 — Tests + docs
|
|
118
|
+
- [ ] Achieve 80%+ pytest coverage
|
|
119
|
+
- [ ] Integration test: spin up fakeredis + SQLite, run full request through FastAPI test client
|
|
120
|
+
- [ ] Write `README.md` with install instructions + 10-line quickstart
|
|
121
|
+
- [ ] Add Chronobot + Livestotell as example integrations
|
|
122
|
+
|
|
123
|
+
## File Structure
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
LockWatch/
|
|
127
|
+
├── PLAN.md
|
|
128
|
+
├── pyproject.toml
|
|
129
|
+
├── README.md (create later)
|
|
130
|
+
├── CHANGELOG.md (create later)
|
|
131
|
+
├── .github/
|
|
132
|
+
│ └── workflows/
|
|
133
|
+
│ └── publish.yml
|
|
134
|
+
├── src/
|
|
135
|
+
│ └── lockwatch/
|
|
136
|
+
│ ├── __init__.py (public API exports)
|
|
137
|
+
│ ├── rate_limiter.py (RateLimiter class)
|
|
138
|
+
│ ├── jwt_rotation.py (JWTRotationManager)
|
|
139
|
+
│ ├── anomaly.py (AnomalyDetector)
|
|
140
|
+
│ ├── audit.py (AuditLogger + SQLAlchemy model)
|
|
141
|
+
│ ├── middleware.py (FastAPI + Flask middleware)
|
|
142
|
+
│ ├── config.py (LockWatchConfig dataclass) [create later]
|
|
143
|
+
│ └── migrations/
|
|
144
|
+
│ ├── env.py [create later]
|
|
145
|
+
│ └── versions/ [create later]
|
|
146
|
+
└── tests/
|
|
147
|
+
├── conftest.py [create later]
|
|
148
|
+
├── test_rate_limiter.py
|
|
149
|
+
├── test_jwt_rotation.py [create later]
|
|
150
|
+
├── test_anomaly.py [create later]
|
|
151
|
+
└── test_middleware.py [create later]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Resume Bullets (multi-domain)
|
|
155
|
+
|
|
156
|
+
**V3 Security/Infra:**
|
|
157
|
+
- Built and published LockWatch to PyPI — a Python middleware library (FastAPI/Flask) implementing sliding-window rate limiting via Redis sorted sets, RS256 JWT rotation with Redis-backed refresh token blacklisting, and IP burst anomaly detection with configurable webhook alerting; integrated into Chronobot and Livestotell
|
|
158
|
+
|
|
159
|
+
**V1 General SWE / V5 Systems:**
|
|
160
|
+
- Designed LockWatch as a zero-dependency security layer: atomic Redis pipeline (ZADD → ZREMRANGEBYSCORE → ZCARD) achieves O(log N) rate limit checks at <1ms overhead per request; Postgres audit log uses async background tasks to avoid adding latency to the hot path
|
|
161
|
+
|
|
162
|
+
**V7 Data / Observability angle:**
|
|
163
|
+
- Implemented per-request audit logging to Postgres via async SQLAlchemy with indexed queries on (timestamp, user_id); anomaly detector emits structured webhook payloads enabling downstream SIEM ingestion
|
|
164
|
+
|
|
165
|
+
## Interview Narrative
|
|
166
|
+
|
|
167
|
+
- **Origin (30s):** At Dialpad I saw how teams bolt on rate limiting as an afterthought — usually a janky NGINX config that doesn't know about users, just IPs. I wanted a Python-native library where you say "limit this user to 100 requests per minute" in two lines, with JWT rotation and audit logging included, not as separate packages.
|
|
168
|
+
|
|
169
|
+
- **Differentiator (30s):** Most rate limit libraries use fixed windows — they have a thundering herd problem at window boundaries. LockWatch uses Redis sorted sets for true sliding windows: ZADD the current timestamp, ZREMRANGEBYSCORE to prune entries older than the window, ZCARD for the count — all in one pipeline, atomic. The JWT rotation piece is rarer: refresh tokens are stored as SHA-256 hashes in Redis (never the raw token), and rotating always blacklists the old JTI before issuing a new pair, so token theft gets caught on the next use.
|
|
170
|
+
|
|
171
|
+
- **Tradeoffs (2 min):** The main tradeoff is Redis dependency — if Redis goes down, you're choosing between failing open (allow all requests, security risk) or failing closed (block all requests, availability risk). I made it configurable: `on_redis_failure='allow'` vs `'deny'`, defaulting to `allow` with an error log, because most APIs would rather serve traffic than go dark. The audit log is async/fire-and-forget — if the Postgres write fails, the request still completes. That's intentional: audit logging should never be in the critical path. The Flask middleware is necessarily sync because WSGI is sync; I bridge to async Redis with a dedicated event loop, which adds ~0.5ms. A pure sync Redis client would be faster but I wanted one code path, not two.
|
|
172
|
+
|
|
173
|
+
- **Extension (1 min):** Natural extensions: (1) distributed rate limiting across multiple Redis nodes using Redlock for the counter key — right now two app servers could both think a key is at N-1 and both allow a request that should be blocked; (2) machine learning on the audit log to detect credential stuffing patterns that don't trigger the burst detector (slow, distributed attacks); (3) a Rust extension module for the hot-path key hashing to get sub-100μs overhead.
|